├── .gitignore ├── .rspec ├── .travis.yml ├── Appraisals ├── Gemfile ├── History.md ├── LICENSE.txt ├── README.md ├── Rakefile ├── acts_as_ordered_tree.gemspec ├── gemfiles ├── rails3.1.gemfile ├── rails3.2.gemfile ├── rails4.0.gemfile ├── rails4.1.gemfile └── rails4.2.gemfile ├── lib ├── acts_as_ordered_tree.rb └── acts_as_ordered_tree │ ├── adapters.rb │ ├── adapters │ ├── abstract.rb │ ├── postgresql.rb │ └── recursive.rb │ ├── compatibility.rb │ ├── compatibility │ ├── active_record │ │ ├── association_scope.rb │ │ ├── default_scoped.rb │ │ └── null_relation.rb │ └── features.rb │ ├── deprecate.rb │ ├── hooks.rb │ ├── hooks │ └── update.rb │ ├── instance_methods.rb │ ├── iterators │ ├── arranger.rb │ ├── level_calculator.rb │ └── orphans_pruner.rb │ ├── node.rb │ ├── node │ ├── attributes.rb │ ├── movement.rb │ ├── movements.rb │ ├── predicates.rb │ ├── reloading.rb │ ├── siblings.rb │ └── traversals.rb │ ├── persevering_transaction.rb │ ├── position.rb │ ├── relation │ ├── arrangeable.rb │ ├── iterable.rb │ └── preloaded.rb │ ├── transaction │ ├── base.rb │ ├── callbacks.rb │ ├── create.rb │ ├── destroy.rb │ ├── dsl.rb │ ├── factory.rb │ ├── move.rb │ ├── passthrough.rb │ ├── reorder.rb │ ├── save.rb │ └── update.rb │ ├── tree.rb │ ├── tree │ ├── association.rb │ ├── callbacks.rb │ ├── children_association.rb │ ├── columns.rb │ ├── deprecated_columns_accessors.rb │ ├── parent_association.rb │ ├── perseverance.rb │ └── scopes.rb │ ├── validators.rb │ └── version.rb └── spec ├── acts_as_ordered_tree_spec.rb ├── adapters ├── postgresql_spec.rb ├── recursive_spec.rb └── shared.rb ├── callbacks_spec.rb ├── counter_cache_spec.rb ├── create_spec.rb ├── destroy_spec.rb ├── inheritance_spec.rb ├── move_spec.rb ├── node ├── movements │ ├── concurrent_movements_spec.rb │ ├── move_higher_spec.rb │ ├── move_lower_spec.rb │ ├── move_to_child_of_spec.rb │ ├── move_to_child_with_index_spec.rb │ ├── move_to_child_with_position_spec.rb │ ├── move_to_left_of_spec.rb │ ├── move_to_right_of_spec.rb │ └── move_to_root_spec.rb ├── predicates_spec.rb ├── reloading_spec.rb ├── siblings_spec.rb └── traversals_spec.rb ├── persevering_transaction_spec.rb ├── relation ├── arrangeable_spec.rb ├── iterable_spec.rb └── preloaded_spec.rb ├── reorder_spec.rb ├── spec_helper.rb ├── support ├── db │ ├── boot.rb │ ├── config.travis.yml │ ├── config.yml │ └── schema.rb ├── factories.rb ├── matchers.rb ├── models.rb └── tree_factory.rb └── tree ├── children_association_spec.rb ├── columns_spec.rb └── scopes_spec.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg/* 5 | .idea/* 6 | coverage/* 7 | .rbx/* 8 | gemfiles/*.lock 9 | *.sqlite3.* -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | cache: bundler 3 | rvm: 4 | - 1.9.3 5 | - 2.0.0 6 | - 2.1.2 7 | - jruby-19mode 8 | before_install: gem install bundler 9 | before_script: 10 | - psql -c 'create database acts_as_ordered_tree_test;' -U postgres 11 | - mysql -e 'create database acts_as_ordered_tree_test;' 12 | env: 13 | - DBCONF=config.travis.yml 14 | gemfile: 15 | - gemfiles/rails3.1.gemfile 16 | - gemfiles/rails3.2.gemfile 17 | - gemfiles/rails4.0.gemfile 18 | - gemfiles/rails4.1.gemfile 19 | - gemfiles/rails4.2.gemfile 20 | script: "bundle exec rake db spec" 21 | after_success: 22 | - bundle exec rake coverage:push 23 | notifications: 24 | recipients: 25 | - amikhailov83@gmail.com -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise 'rails3.1' do 2 | gem 'activerecord', '~> 3.1.12' 3 | 4 | gem 'activerecord-jdbcpostgresql-adapter', '~> 1.2.2', :platform => :jruby 5 | gem 'activerecord-jdbcsqlite3-adapter', '~> 1.2.2', :platform => :jruby 6 | gem 'activerecord-jdbcmysql-adapter', '~> 1.2.2', :platform => :jruby 7 | end 8 | 9 | appraise 'rails3.2' do 10 | gem 'activerecord', '~> 3.2.17' 11 | 12 | gem 'activerecord-jdbcpostgresql-adapter', '~> 1.2.2', :platform => :jruby 13 | gem 'activerecord-jdbcsqlite3-adapter', '~> 1.2.2', :platform => :jruby 14 | gem 'activerecord-jdbcmysql-adapter', '~> 1.2.2', :platform => :jruby 15 | end 16 | 17 | appraise 'rails4.0' do 18 | gem 'activerecord', '~> 4.0.3' 19 | 20 | gem 'activerecord-jdbcpostgresql-adapter', '~> 1.3.6', :platform => :jruby 21 | gem 'activerecord-jdbcsqlite3-adapter', '~> 1.3.6', :platform => :jruby 22 | gem 'activerecord-jdbcmysql-adapter', '~> 1.3.6', :platform => :jruby 23 | end 24 | 25 | appraise 'rails4.1' do 26 | gem 'activerecord', '~> 4.1.6' 27 | 28 | gem 'activerecord-jdbcpostgresql-adapter', '~> 1.3.6', :platform => :jruby 29 | gem 'activerecord-jdbcsqlite3-adapter', '~> 1.3.6', :platform => :jruby 30 | gem 'activerecord-jdbcmysql-adapter', '~> 1.3.6', :platform => :jruby 31 | end 32 | 33 | appraise 'rails4.2' do 34 | gem 'activerecord', '~> 4.2.0' 35 | 36 | gem 'activerecord-jdbcpostgresql-adapter', '~> 1.3.6', :platform => :jruby 37 | gem 'activerecord-jdbcsqlite3-adapter', '~> 1.3.6', :platform => :jruby 38 | gem 'activerecord-jdbcmysql-adapter', '~> 1.3.6', :platform => :jruby 39 | end -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | # Specify your gem's dependencies in acts_as_ordered_tree.gemspec 4 | gemspec 5 | 6 | gem 'pg', :platform => :ruby 7 | gem 'sqlite3', :platform => :ruby 8 | gem 'mysql2', :platform => :ruby 9 | gem 'simplecov', :platform => :ruby 10 | gem 'coveralls', :platform => :ruby 11 | 12 | gem 'activerecord-jdbcpostgresql-adapter', '~> 1.2.0', :platform => :jruby 13 | gem 'activerecord-jdbcsqlite3-adapter', '~> 1.2.0', :platform => :jruby 14 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | # 2.0.0 (not released yet) 2 | 3 | The library completely redesigned, tons of refactorings applied. 4 | 5 | ### Breaking changes 6 | 7 | * Movement methods now use `ActiveRecord::Base#save` so all 8 | user validations and callbacks are invoked now. 9 | * `parent_id` and `position` attributes are no longer protected 10 | by default. It's up to developer to make them `attr_protected` now. 11 | 12 | ### New features 13 | 14 | * Reduced amount of UPDATE SQL-queries during movements and reorders. 15 | * Added collection iterators: 16 | 1. `each_with_level` which yields each node of collection with its level. 17 | 2. `each_without_orphans` which yields only that nodes which are not orphaned 18 | (i.e. that nodes which don't have corresponding parent within iterated collection). 19 | 3. `arrange` which constructs hash of hashes where each key is node and value 20 | is its hash of its children. 21 | * Added ability to explicitly set left/right sibling for node via 22 | `#left_sibling=` and `#right_sibling=` methods (#30). 23 | * `.leaves` scope now works even with models that don't have `counter_cache` column (#29). 24 | * Added method instance method `#move_to_child_with_position` 25 | which is similar to `#move_to_child_with_index` but is more human readable. 26 | * Full support for `before_add`, `after_add`, `before_remove` and `after_remove` 27 | callbacks (#25). 28 | * Descendants now can be arranged into hash with `#arrange` method (#22). 29 | * Flexible control over tree traversals via blocks passed to `#descendants` 30 | and `#ancestors` (#21). 31 | 32 | ### Deprecations 33 | 34 | * Deprecated methods: 35 | 1. `#insert_at`. It is recommended to use `#move_to_child_with_position` instead. 36 | 2. `#branch? is deprecated in favour of `#has_children?`. 37 | 3. `#child?` is deprecated in favour of `#has_parent?`. 38 | 4. `#move_possible?` is deprecated in favour of built-in and user defined validations. 39 | 40 | Bug fixes: 41 | 42 | * Fixed several issues that broke tree integrity. 43 | * Fixed bug when two root nodes could be created with same position (#24). 44 | * Fixed support of STI and basic inheritance. 45 | * Fixed bug when user validations and callbacks weren't invoked on movements 46 | via `move_to_*` methods (#23). -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2014 Alexei Mikhailov 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Acts As Ordered Tree [![Build Status](https://secure.travis-ci.org/take-five/acts_as_ordered_tree.png?branch=master)](http://travis-ci.org/take-five/acts_as_ordered_tree) [![Code Climate](https://codeclimate.com/github/take-five/acts_as_ordered_tree.png)](https://codeclimate.com/github/take-five/acts_as_ordered_tree) [![Coverage Status](https://coveralls.io/repos/take-five/acts_as_ordered_tree/badge.png)](https://coveralls.io/r/take-five/acts_as_ordered_tree) 2 | WARNING! THIS GEM IS NOT COMPATIBLE WITH ordered_tree gem. 3 | 4 | Specify this `acts_as` extension if you want to model an ordered tree structure ([adjacency list hierarchical structure](http://www.sqlsummit.com/AdjacencyList.htm)) by providing a parent association, a children association and a sort column. For proper use you should have a foreign key column, which by default is called `parent_id`, and a sort column, which is by default called `position`. 5 | Comparison of Adjacency List model to others: http://vadimtropashko.wordpress.com/2008/08/09/one-more-nested-intervals-vs-adjacency-list-comparison/ 6 | 7 | This extension is mostly compatible with [`awesome_nested_set`](https://github.com/collectiveidea/awesome_nested_set/) gem 8 | 9 | ## Requirements 10 | 11 | Gem is supposed to work with Rails 3.1 and higher including newest Rails 4.2. We test it with `ruby-1.9.3`, `ruby-2.0.0`, `ruby-2.1.0` and `jruby-1.7.8`. Sorry, support for ruby 1.9.2 and 1.8.7 is dropped. Also, `rubunius` isn't supported since it's quite unstable (I could not even launch rails 3.2 with rbx-2.1.1). 12 | 13 | ## Features 14 | 1. Supports PostgreSQL recursive queries (requires at least `postgresql-8.3`) 15 | 2. Holds integrity control via pessimistic database locks. Common situation for `acts_as_list` users is non-unique positions within list. It happens when two concurrent users modify list sumultaneously. `acts_as_ordered_tree` uses pessimistic locks to keep your tree consistent. 16 | 17 | ## Installation 18 | Install it via rubygems: 19 | 20 | ```bash 21 | gem install acts_as_ordered_tree 22 | ``` 23 | 24 | ## Usage 25 | 26 | To make use of `acts_as_ordered_tree`, your model needs to have 2 fields: parent_id and position. You can also have an optional fields: `depth` and `children_count`: 27 | ```ruby 28 | class CreateCategories < ActiveRecord::Migration 29 | def self.up 30 | create_table :categories do |t| 31 | t.integer :company_id 32 | t.string :name 33 | t.integer :parent_id # this is mandatory 34 | t.integer :position # this is mandatory 35 | t.integer :depth # this is optional 36 | t.integer :children_count # this is optional 37 | end 38 | end 39 | 40 | def self.down 41 | drop_table :categories 42 | end 43 | end 44 | ``` 45 | 46 | Setup your model: 47 | 48 | ```ruby 49 | class Category < ActiveRecord::Base 50 | acts_as_ordered_tree 51 | 52 | # gem introduces new ActiveRecord callbacks: 53 | # *_reorder - fires when position (but not parent node) is changed 54 | # *_move - fires when parent node is changed 55 | before_reorder :do_smth 56 | before_move :do_smth_else 57 | end 58 | ``` 59 | 60 | Now you can use `acts_as_ordered_tree` features: 61 | 62 | ```ruby 63 | # root 64 | # \_ child1 65 | # \_ subchild1 66 | # \_ subchild2 67 | 68 | 69 | root = Category.create(:name => "root") 70 | child1 = root.children.create(:name => "child1") 71 | subchild1 = child1.children.create("name" => "subchild1") 72 | subchild2 = child1.children.create("name" => "subchild2") 73 | 74 | Category.roots # => [root] 75 | 76 | root.root? # => true 77 | root.parent # => nil 78 | root.ancestors # => [] 79 | root.descendants # => [child1, subchild1, subchild2] 80 | root.descendants.arrange # => {child1 => {subchild1 => {}, subchild2 => {}}} 81 | # you may pass an option to discard possible orphans from selection 82 | root.descendants.arrange(:orphans => :discard) 83 | 84 | child1.parent # => root 85 | child1.ancestors # => [root] 86 | child1.children # => [subchild1, subchild2] 87 | child1.descendants # => [subchild1, subchild2] 88 | child1.root? # => false 89 | child1.leaf? # => false 90 | 91 | subchild1.ancestors # => [child1, root] 92 | subchild1.root # => [root] 93 | subchild1.leaf? # => true 94 | subchild1.first? # => true 95 | subchild1.last? # => false 96 | subchild2.last? # => true 97 | 98 | subchild1.move_to_above_of(child1) 99 | subchild1.move_to_bottom_of(child1) 100 | subchild1.move_to_child_of(root) 101 | subchild1.move_lower 102 | subchild1.move_higher 103 | ``` 104 | 105 | ## Contributing 106 | 107 | 1. Fork it 108 | 2. Create your feature branch (`git checkout -b my-new-feature`) 109 | 3. Commit your changes (`git commit -am 'Added some feature'`) 110 | 4. Push to the branch (`git push origin my-new-feature`) 111 | 5. Create new Pull Request 112 | 113 | ## TODO 114 | 1. Fix README typos and grammatical errors (english speaking contributors are welcomed) 115 | 2. Add moar examples and docs. 116 | 3. Implement converter from other structures (nested_set, closure_tree) -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | require 'yaml' 4 | require 'erb' 5 | 6 | namespace :db do 7 | config_name = ENV['DBCONF'] || 'config.yml' 8 | 9 | databases = YAML.load(ERB.new(IO.read(File.join(File.dirname(__FILE__), 'spec', 'support', 'db', config_name))).result) 10 | 11 | databases.each do |name, spec| 12 | desc "Run given task for #{spec['adapter']} database" 13 | task name do 14 | with_database(name) do 15 | announce 16 | exec 17 | end 18 | end 19 | end 20 | 21 | task :all do 22 | require 'benchmark' 23 | 24 | time = Benchmark.realtime do 25 | databases.keys.each do |name| 26 | with_database(name) do 27 | announce 28 | run 29 | end 30 | end 31 | end 32 | 33 | puts 34 | puts 'Time taken: %.2f sec' % time 35 | 36 | exit 37 | end 38 | 39 | private 40 | def exec 41 | Kernel.exec(ENV, *command) 42 | end 43 | 44 | def run 45 | unless Kernel.system(ENV, *command) 46 | exit(1) 47 | end 48 | end 49 | 50 | def with_database(name) 51 | ENV['DB'] = name 52 | 53 | yield 54 | ensure 55 | ENV['DB'] = nil 56 | end 57 | 58 | def command 59 | ['bundle', 'exec', 'rake', *ARGV.slice(1, ARGV.size)] 60 | end 61 | 62 | def announce 63 | puts ">> DB=#{ENV['DB']} #{command.join(' ')}" 64 | end 65 | end 66 | 67 | desc 'Run given task for all databases' 68 | task :db => 'db:all' 69 | 70 | RSpec::Core::RakeTask.new(:spec) do |t| 71 | t.rspec_opts = '--color --format progress' 72 | end 73 | 74 | 75 | begin 76 | require 'coveralls/rake/task' 77 | Coveralls::RakeTask.new 78 | rescue LoadError, NameError 79 | task 'coveralls:push' 80 | end 81 | 82 | namespace :coverage do 83 | desc 'Turn on code coverage' 84 | task :enable do 85 | ENV['COVERAGE'] = '1' unless ENV.key?('COVERAGE') 86 | end 87 | 88 | desc 'Turn off code coverage' 89 | task :disable do 90 | ENV['COVERAGE'] = '' 91 | end 92 | 93 | desc 'Push code coverage to coveralls' 94 | task :push => 'coveralls:push' 95 | end 96 | 97 | task :spec => 'coverage:enable' -------------------------------------------------------------------------------- /acts_as_ordered_tree.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path('../lib', __FILE__) 3 | require 'acts_as_ordered_tree/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'acts_as_ordered_tree' 7 | s.version = ActsAsOrderedTree::VERSION 8 | s.authors = ['Alexei Mikhailov', 'Vladimir Kuznetsov'] 9 | s.email = %w(amikhailov83@gmail.com kv86@mail.ru) 10 | s.homepage = 'https://github.com/take-five/acts_as_ordered_tree' 11 | s.summary = %q{ActiveRecord extension for sorted adjacency lists support} 12 | 13 | s.rubyforge_project = 'acts_as_ordered_tree' 14 | 15 | s.files = `git ls-files -- lib/*`.split("\n") 16 | s.test_files = `git ls-files -- spec/* features/*`.split("\n") 17 | s.require_paths = %w(lib) 18 | 19 | s.add_dependency 'activerecord', '>= 3.1.0' 20 | s.add_dependency 'activerecord-hierarchical_query', '~> 0.0.7' 21 | 22 | s.add_development_dependency 'rake', '~> 10.3.2' 23 | s.add_development_dependency 'bundler', '~> 1.5' 24 | s.add_development_dependency 'rspec', '~> 2.99.0' 25 | s.add_development_dependency 'database_cleaner', '~> 1.3.0' 26 | s.add_development_dependency 'factory_girl', '~> 4.4.0' 27 | s.add_development_dependency 'appraisal', '>= 1.0.2' 28 | end -------------------------------------------------------------------------------- /gemfiles/rails3.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "http://rubygems.org" 4 | 5 | gem "pg", :platform => :ruby 6 | gem "sqlite3", :platform => :ruby 7 | gem "mysql2", "~> 0.3.20", :platform => :ruby 8 | gem "simplecov", :platform => :ruby 9 | gem "coveralls", :platform => :ruby 10 | gem "activerecord-jdbcpostgresql-adapter", "~> 1.2.2", :platform => :jruby 11 | gem "activerecord-jdbcsqlite3-adapter", "~> 1.2.2", :platform => :jruby 12 | gem "activerecord", "~> 3.1.12" 13 | gem "activerecord-jdbcmysql-adapter", "~> 1.2.2", :platform => :jruby 14 | 15 | gemspec :path => "../" 16 | -------------------------------------------------------------------------------- /gemfiles/rails3.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "http://rubygems.org" 4 | 5 | gem "pg", :platform => :ruby 6 | gem "sqlite3", :platform => :ruby 7 | gem "mysql2", "~> 0.3.20", :platform => :ruby 8 | gem "simplecov", :platform => :ruby 9 | gem "coveralls", :platform => :ruby 10 | gem "activerecord-jdbcpostgresql-adapter", "~> 1.2.2", :platform => :jruby 11 | gem "activerecord-jdbcsqlite3-adapter", "~> 1.2.2", :platform => :jruby 12 | gem "activerecord", "~> 3.2.17" 13 | gem "activerecord-jdbcmysql-adapter", "~> 1.2.2", :platform => :jruby 14 | 15 | gemspec :path => "../" 16 | -------------------------------------------------------------------------------- /gemfiles/rails4.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "http://rubygems.org" 4 | 5 | gem "pg", :platform => :ruby 6 | gem "sqlite3", :platform => :ruby 7 | gem "mysql2", "~> 0.3.20", :platform => :ruby 8 | gem "simplecov", :platform => :ruby 9 | gem "coveralls", :platform => :ruby 10 | gem "activerecord-jdbcpostgresql-adapter", "~> 1.3.6", :platform => :jruby 11 | gem "activerecord-jdbcsqlite3-adapter", "~> 1.3.6", :platform => :jruby 12 | gem "activerecord", "~> 4.0.3" 13 | gem "activerecord-jdbcmysql-adapter", "~> 1.3.6", :platform => :jruby 14 | 15 | gemspec :path => "../" 16 | -------------------------------------------------------------------------------- /gemfiles/rails4.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "http://rubygems.org" 4 | 5 | gem "pg", :platform => :ruby 6 | gem "sqlite3", :platform => :ruby 7 | gem "mysql2", "~> 0.3.20", :platform => :ruby 8 | gem "simplecov", :platform => :ruby 9 | gem "coveralls", :platform => :ruby 10 | gem "activerecord-jdbcpostgresql-adapter", "~> 1.3.6", :platform => :jruby 11 | gem "activerecord-jdbcsqlite3-adapter", "~> 1.3.6", :platform => :jruby 12 | gem "activerecord", "~> 4.1.6" 13 | gem "activerecord-jdbcmysql-adapter", "~> 1.3.6", :platform => :jruby 14 | 15 | gemspec :path => "../" 16 | -------------------------------------------------------------------------------- /gemfiles/rails4.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "http://rubygems.org" 4 | 5 | gem "pg", :platform => :ruby 6 | gem "sqlite3", :platform => :ruby 7 | gem "mysql2", "~> 0.4.2", :platform => :ruby 8 | gem "simplecov", :platform => :ruby 9 | gem "coveralls", :platform => :ruby 10 | gem "activerecord-jdbcpostgresql-adapter", "~> 1.3.19", :platform => :jruby 11 | gem "activerecord-jdbcsqlite3-adapter", "~> 1.3.19", :platform => :jruby 12 | gem "activerecord", "~> 4.2.0" 13 | gem "activerecord-jdbcmysql-adapter", "~> 1.3.19", :platform => :jruby 14 | 15 | gemspec :path => "../" 16 | -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree.rb: -------------------------------------------------------------------------------- 1 | require 'acts_as_ordered_tree/version' 2 | require 'active_support/lazy_load_hooks' 3 | 4 | module ActsAsOrderedTree 5 | autoload :Tree, 'acts_as_ordered_tree/tree' 6 | 7 | # @!attribute [r] ordered_tree 8 | # @return [ActsAsOrderedTree::Tree] ordered tree object 9 | 10 | # == Usage 11 | # class Category < ActiveRecord::Base 12 | # acts_as_ordered_tree :parent_column => :parent_id, 13 | # :position_column => :position, 14 | # :depth_column => :depth, 15 | # :counter_cache => :children_count 16 | # end 17 | def acts_as_ordered_tree(options = {}) 18 | Tree.setup!(self, options) 19 | end 20 | 21 | # @api private 22 | def self.extended(base) 23 | base.class_attribute :ordered_tree, :instance_writer => false 24 | end 25 | 26 | # Rebuild ordered tree structure for subclasses. It needs to be rebuilt 27 | # mainly because of :children and :parent associations, which are created 28 | # with option :class_name. It matters for class hierarchies without STI, 29 | # they can't work properly with associations inherited from superclass. 30 | # 31 | # @api private 32 | def inherited(subclass) 33 | super 34 | 35 | subclass.acts_as_ordered_tree(ordered_tree.options) if ordered_tree? 36 | end 37 | end # module ActsAsOrderedTree 38 | 39 | ActiveSupport.on_load(:active_record) do 40 | extend ActsAsOrderedTree 41 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/adapters.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'active_support/hash_with_indifferent_access' 4 | require 'acts_as_ordered_tree/adapters/recursive' 5 | require 'acts_as_ordered_tree/adapters/postgresql' 6 | 7 | module ActsAsOrderedTree 8 | module Adapters 9 | # adapters map 10 | ADAPTERS = HashWithIndifferentAccess['PostgreSQL' => PostgreSQL] 11 | ADAPTERS.default = Recursive 12 | 13 | def self.lookup(name) 14 | ADAPTERS[name] 15 | end 16 | end 17 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/adapters/abstract.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | module ActsAsOrderedTree 4 | module Adapters 5 | class Abstract 6 | attr_reader :tree 7 | 8 | # @param [ActsAsOrderedTree::Tree] tree 9 | def initialize(tree) 10 | @tree = tree 11 | end 12 | 13 | protected 14 | def preloaded(records) 15 | tree.klass.where(nil).extending(Relation::Preloaded).records(records) 16 | end 17 | 18 | def none 19 | tree.klass.where(nil).none 20 | end 21 | end # class Abstract 22 | end # module Adapters 23 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/adapters/postgresql.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'active_record/hierarchical_query' 4 | 5 | require 'acts_as_ordered_tree/adapters/abstract' 6 | require 'acts_as_ordered_tree/relation/preloaded' 7 | 8 | module ActsAsOrderedTree 9 | module Adapters 10 | # PostgreSQL adapter implements traverse operations with CTEs 11 | class PostgreSQL < Abstract 12 | attr_reader :tree 13 | 14 | delegate :columns, :to => :tree 15 | delegate :quote_column_name, :to => 'tree.klass.connection' 16 | 17 | def self_and_descendants(node, &block) 18 | traverse_down(node) do 19 | descendants_scope(node.ordered_tree_node, &block) 20 | end 21 | end 22 | 23 | def descendants(node, &block) 24 | traverse_down(node) do 25 | without(node) { self_and_descendants(node, &block) } 26 | end 27 | end 28 | 29 | def self_and_ancestors(node, &block) 30 | traverse_up(node, [node]) do 31 | ancestors_scope(node.ordered_tree_node, &block) 32 | end 33 | end 34 | 35 | def ancestors(node, &block) 36 | traverse_up(node) do 37 | without(node) { self_and_ancestors(node, &block) } 38 | end 39 | end 40 | 41 | private 42 | def without(node) 43 | scope = yield 44 | scope.where(scope.table[columns.id].not_eq(node.id)) 45 | end 46 | 47 | def traverse_down(node) 48 | if node && node.persisted? 49 | yield 50 | else 51 | none 52 | end 53 | end 54 | 55 | # Yields to block if record is persisted and its parent was not changed. 56 | # Returns empty scope (or scope with +including+ records) if record is root. 57 | # Otherwise recursively fetches ancestors and returns preloaded relation. 58 | def traverse_up(node, including = []) 59 | return none unless node 60 | 61 | if can_traverse_up?(node) 62 | if node.ordered_tree_node.has_parent? 63 | yield 64 | else 65 | including.empty? ? none : preloaded(including) 66 | end 67 | else 68 | preloaded(persisted_ancestors(node) + including) 69 | end 70 | end 71 | 72 | # Generates scope that traverses tree down to deep, starting from given +scope+ 73 | def descendants_scope(node) 74 | node.scope.join_recursive do |query| 75 | query.connect_by(join_columns(columns.id => columns.parent)) 76 | .start_with(node.to_relation) 77 | 78 | yield query if block_given? 79 | 80 | query.order_siblings(position) 81 | end 82 | end 83 | 84 | # Generates scope that traverses tree up to root, starting from given +scope+ 85 | def ancestors_scope(node, &block) 86 | if columns.depth? 87 | build_ancestors_query(node, &block).reorder(depth) 88 | else 89 | build_ancestors_query(node) do |query| 90 | query.start_with { |start| start.select Arel.sql('0').as('__depth') } 91 | .select(query.prior['__depth'] - 1, :start_with => false) 92 | 93 | yield query if block_given? 94 | end.reorder('__depth') 95 | end 96 | end 97 | 98 | def build_ancestors_query(node) 99 | node.scope.join_recursive do |query| 100 | query.connect_by(join_columns(columns.parent => columns.id)) 101 | .start_with(node.to_relation) 102 | 103 | yield query if block_given? 104 | end 105 | end 106 | 107 | def attribute(name) 108 | @tree.klass.arel_table[name] 109 | end 110 | 111 | def depth 112 | attribute(columns.depth) 113 | end 114 | 115 | def position 116 | attribute(columns.position) 117 | end 118 | 119 | def can_traverse_up?(node) 120 | node.persisted? && !node.ordered_tree_node.parent_id_changed? 121 | end 122 | 123 | # Recursively fetches node's parents until one of them will be persisted. 124 | # Returns persisted ancestor and array of non-persistent ancestors 125 | def persisted_ancestors(node) 126 | queue = [] 127 | 128 | parent = node 129 | 130 | while (parent = parent.parent) 131 | break if parent && parent.persisted? 132 | 133 | queue.unshift(parent) 134 | end 135 | 136 | ancestors(parent) + [parent].compact + queue 137 | end 138 | 139 | def scope_columns_hash 140 | Hash[tree.columns.scope.map { |x| [x, x] }] 141 | end 142 | 143 | def join_columns(hash) 144 | scope_columns_hash.merge(hash).each_with_object({}) do |(k, v), h| 145 | h[k.to_sym] = v.to_sym 146 | end 147 | end 148 | end # class PostgreSQL 149 | end # module Adapters 150 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/adapters/recursive.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/adapters/abstract' 4 | 5 | module ActsAsOrderedTree 6 | module Adapters 7 | # Recursive adapter implements tree traversal in pure Ruby. 8 | class Recursive < Abstract 9 | def self_and_ancestors(node, &block) 10 | return none unless node 11 | 12 | ancestors_scope(node, :include_first => true, &block) 13 | end 14 | 15 | def ancestors(node, &block) 16 | ancestors_scope(node, :include_first => false, &block) 17 | end 18 | 19 | def descendants(node, &block) 20 | descendants_scope(node, :include_first => false, &block) 21 | end 22 | 23 | def self_and_descendants(node, &block) 24 | descendants_scope(node, :include_first => true, &block) 25 | end 26 | 27 | private 28 | def ancestors_scope(node, options, &block) 29 | traversal = Traversal.new(node, options, &block) 30 | traversal.follow :parent 31 | traversal.to_scope.reverse_order! 32 | end 33 | 34 | def descendants_scope(node, options, &block) 35 | return none unless node.persisted? 36 | 37 | traversal = Traversal.new(node, options, &block) 38 | traversal.follow :children 39 | traversal.to_scope 40 | end 41 | 42 | class Traversal 43 | delegate :klass, :to => :@start_record 44 | attr_accessor :include_first 45 | 46 | def initialize(start_record, options = {}) 47 | @start_record = start_record 48 | @start_with = nil 49 | @order_values = [] 50 | @where_values = [] 51 | @include_first = options[:include_first] 52 | follow(options[:follow]) if options.key?(:follow) 53 | 54 | yield self if block_given? 55 | end 56 | 57 | def follow(association_name) 58 | @association = association_name 59 | 60 | self 61 | end 62 | 63 | def start_with(scope = nil, &block) 64 | @start_with = scope || block 65 | 66 | self 67 | end 68 | 69 | def order_siblings(*values) 70 | @order_values << values 71 | 72 | self 73 | end 74 | alias_method :order, :order_siblings 75 | 76 | def where(*values) 77 | @where_values << values 78 | 79 | self 80 | end 81 | 82 | def table 83 | klass.arel_table 84 | end 85 | 86 | def klass 87 | @start_record.class 88 | end 89 | 90 | def to_scope 91 | null_scope.records(to_enum.to_a) 92 | end 93 | 94 | private 95 | def each(&block) 96 | return unless validate_start_conditions 97 | 98 | yield @start_record if include_first 99 | 100 | expand(@start_record, &block) 101 | end 102 | 103 | def validate_start_conditions 104 | start_scope ? start_scope.exists? : true 105 | end 106 | 107 | def start_scope 108 | return nil unless @start_with 109 | 110 | if @start_with.is_a?(Proc) 111 | @start_with.call klass.where(klass.primary_key => @start_record.id) 112 | else 113 | @start_with 114 | end 115 | end 116 | 117 | def expand(record, &block) 118 | expand_association(record).each do |child| 119 | yield child 120 | 121 | expand(child, &block) 122 | end 123 | end 124 | 125 | def expand_association(record) 126 | if constraints? 127 | build_scope(record) 128 | else 129 | follow_association(record) 130 | end 131 | end 132 | 133 | def build_scope(record) 134 | scope = record.association(@association).scope 135 | 136 | @where_values.each { |v| scope = scope.where(*v) } 137 | scope = scope.except(:order).order(*@order_values.flatten) if @order_values.any? 138 | 139 | scope 140 | end 141 | 142 | def follow_association(record) 143 | Array.wrap(record.send(@association)) 144 | end 145 | 146 | def null_scope 147 | klass.where(nil).extending(Relation::Preloaded) 148 | end 149 | 150 | def constraints? 151 | @where_values.any? || @order_values.any? 152 | end 153 | end 154 | private_constant :Traversal 155 | end # class Recursive 156 | end # module Adapters 157 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/compatibility.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/compatibility/features' 4 | 5 | module ActsAsOrderedTree 6 | # Since we support multiple Rails versions, we need to turn on some features 7 | # for old Rails versions. 8 | # 9 | # @api private 10 | module Compatibility 11 | features do 12 | scope :active_record do 13 | versions '< 4.0.0' do 14 | feature :association_scope 15 | feature :null_relation 16 | end 17 | 18 | feature :default_scoped, '< 4.1.0' 19 | end 20 | end 21 | end 22 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/compatibility/active_record/association_scope.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module Associations 3 | class Association 4 | def scope 5 | scoped 6 | end 7 | end 8 | end 9 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/compatibility/active_record/default_scoped.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module Scoping 3 | module Default 4 | module ClassMethods 5 | # default_scoped is a new method from Rails 4.1. 6 | # Used in RecursiveRelation 7 | def default_scoped 8 | scope = relation.merge(send(:build_default_scope)) 9 | scope.default_scoped = true 10 | scope 11 | end 12 | end 13 | end 14 | end 15 | end 16 | 17 | ActsAsOrderedTree::Compatibility.version '< 3.2.0' do 18 | ActiveRecord::Base.extend(ActiveRecord::Scoping::Default::ClassMethods) 19 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/compatibility/active_record/null_relation.rb: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | module ActiveRecord 4 | module NullRelation # :nodoc: 5 | def exec_queries 6 | @records = [] 7 | end 8 | 9 | def to_a 10 | [] 11 | end 12 | 13 | def pluck(*column_names) 14 | [] 15 | end 16 | 17 | def delete_all(_conditions = nil) 18 | 0 19 | end 20 | 21 | def update_all(_updates, _conditions = nil, _options = {}) 22 | 0 23 | end 24 | 25 | def delete(_id_or_array) 26 | 0 27 | end 28 | 29 | def size 30 | 0 31 | end 32 | 33 | def empty? 34 | true 35 | end 36 | 37 | def any? 38 | false 39 | end 40 | 41 | def many? 42 | false 43 | end 44 | 45 | def to_sql 46 | @to_sql ||= "" 47 | end 48 | 49 | def count(*) 50 | 0 51 | end 52 | 53 | def sum(*) 54 | 0 55 | end 56 | 57 | def calculate(_operation, _column_name, _options = {}) 58 | nil 59 | end 60 | 61 | def exists?(_id = false) 62 | false 63 | end 64 | end 65 | 66 | class Relation 67 | def none 68 | extending(NullRelation) 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/compatibility/features.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'tsort' 4 | 5 | require 'active_support/core_ext/string/inflections' 6 | require 'active_support/core_ext/array/wrap' 7 | 8 | module ActsAsOrderedTree 9 | module Compatibility 10 | UnknownFeature = Class.new(StandardError) 11 | 12 | class Feature 13 | class Version 14 | def initialize(operator, version = nil) 15 | operator, version = operator.split unless version 16 | operator, version = '=', operator unless version 17 | operator = '==' if operator == '=' 18 | 19 | @operator, @version = operator, version.to_s 20 | end 21 | 22 | def matches? 23 | ActiveRecord::VERSION::STRING.send(@operator, @version) 24 | end 25 | 26 | def to_s 27 | [@operator, @version].join(' ') 28 | end 29 | end 30 | 31 | attr_reader :name, :versions, :prerequisites 32 | 33 | def initialize(name, versions, prerequisites) 34 | @name = name.to_s 35 | @versions = Array.wrap(versions).map { |v| Version.new(v) } 36 | @prerequisites = Array.wrap(prerequisites) 37 | end 38 | 39 | # Requires dependency 40 | def require 41 | Kernel.require(path) if @versions.all?(&:matches?) 42 | end 43 | 44 | private 45 | def path 46 | "acts_as_ordered_tree/compatibility/#{name}" 47 | end 48 | end 49 | 50 | class DependencyTree 51 | include TSort 52 | 53 | def initialize 54 | @features = Hash.new 55 | end 56 | 57 | def require 58 | @features.each_value(&:require) 59 | end 60 | 61 | def <<(feature) 62 | feature.prerequisites.each do |pre| 63 | unless @features.key?(pre.to_s) 64 | @features[pre.to_s] = Feature.new(pre, feature.versions.map(&:to_s), []) 65 | end 66 | end 67 | 68 | @features[feature.name] = feature 69 | end 70 | 71 | def [](name) 72 | @features[name.to_s] 73 | end 74 | 75 | def each_dependency(name, &block) 76 | raise UnknownFeature, "Unknown compatibility feature #{name}" unless @features.key?(name.to_s) 77 | 78 | each_strongly_connected_component_from(name.to_s, &block) 79 | end 80 | 81 | private 82 | def tsort_each_node(&block) 83 | @features.each_key(&block) 84 | end 85 | 86 | def tsort_each_child(node, &block) 87 | @features[node].prerequisites.each(&block) if @features[node] 88 | end 89 | end 90 | 91 | class DependencyTreeBuilder 92 | attr_reader :tree 93 | 94 | def initialize 95 | @tree = DependencyTree.new 96 | @default_versions = nil 97 | @prerequisites = [] 98 | @scope = '' 99 | end 100 | 101 | def versions(*versions, &block) 102 | @default_versions = versions 103 | 104 | instance_eval(&block) 105 | ensure 106 | @default_versions = nil 107 | end 108 | alias_method :version, :versions 109 | 110 | def scope(name, &block) 111 | if name.is_a?(Hash) 112 | @scope = name.keys.first.to_s 113 | @prerequisites = Array.wrap(name.values.first) 114 | else 115 | @scope = name.to_s 116 | end 117 | 118 | instance_eval(&block) 119 | ensure 120 | @scope = '' 121 | @prerequisites = [] 122 | end 123 | 124 | def feature(name, options = {}) 125 | @tree << if name.is_a?(Hash) 126 | version = name.delete(:versions) || @default_versions 127 | name, prereq = *name.first 128 | 129 | prereq = @prerequisites + Array.wrap(prereq).map { |x| [@scope, x].join('/') } 130 | 131 | Feature.new([@scope, name].join('/'), version, prereq) 132 | else 133 | version = options.is_a?(Hash) ? options.delete(:versions) : options 134 | Feature.new([@scope, name].join('/'), version || @default_versions, @prerequisites) 135 | end 136 | end 137 | end 138 | 139 | module DSL 140 | def features(&block) 141 | builder = DependencyTreeBuilder.new 142 | builder.instance_eval(&block) 143 | builder.tree.require 144 | end 145 | 146 | def version(*versions) 147 | versions = versions.map { |v| Feature::Version.new(*v) } 148 | yield if versions.all?(&:matches?) 149 | end 150 | end 151 | extend DSL 152 | end 153 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/deprecate.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | module ActsAsOrderedTree 4 | # @api private 5 | module Deprecate 6 | NEXT_VERSION = '2.1' 7 | 8 | def deprecated_method(method, replacement = nil, &block) 9 | define_method(method) do |*args, &method_block| 10 | message = "#{self.class.name}##{__method__} is "\ 11 | "deprecated and will be removed in acts_as_ordered_tree-#{NEXT_VERSION}" 12 | message << ", use ##{replacement} instead" if replacement 13 | 14 | ActiveSupport::Deprecation.warn message, caller(2) 15 | 16 | if block 17 | instance_exec(*args, &block) 18 | elsif replacement 19 | __send__(replacement, *args, &method_block) 20 | end 21 | end 22 | end 23 | end 24 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/hooks.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'active_support/concern' 4 | 5 | require 'acts_as_ordered_tree/hooks/update' 6 | 7 | module ActsAsOrderedTree 8 | # Included into AR::Base this module allows to intercept 9 | # internal AR calls, such as +create_record+ and execute 10 | # patched code. 11 | # 12 | # Hooks intention is to execute well optimized INSERTs and 13 | # UPDATEs at certain cases. 14 | # 15 | # @example 16 | # class Category < ActiveRecord::Base 17 | # include ActsAsOrderedTree::Hooks 18 | # end 19 | # 20 | # category.hook_update do |update| 21 | # update.scope = category.parent.children 22 | # update.values = {:counter => Category.arel_table[:counter] + 1} 23 | # 24 | # # all callbacks, including :before_save and :after_save will 25 | # # be invoked, but patched UPDATE will be called instead of 26 | # # original AR `ActiveRecord::Persistence#update_record` 27 | # category.save 28 | # end 29 | # 30 | # @api private 31 | module Hooks 32 | extend ActiveSupport::Concern 33 | 34 | included do 35 | include Update 36 | end 37 | end 38 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/hooks/update.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'active_support/concern' 4 | 5 | module ActsAsOrderedTree 6 | module Hooks 7 | # This AR-hook is used in Move transactions to update parent_id, position 8 | # and other changed attributes using single SQL-query. 9 | # 10 | # @example 11 | # class Category < ActiveRecord::Base 12 | # include ActsAsOrderedTree::Hooks 13 | # end 14 | # 15 | # category = Category.first 16 | # category.hook_update do |update| 17 | # update.scope = Category.where(:parent_id => category.parent_id) 18 | # update.values = { :name => Arel.sql('CASE WHEN parent_id IS NULL THEN name ELSE name || name END') } 19 | # 20 | # # `update.update!` will be called instead of usual `AR::Persistence#update` 21 | # record.save 22 | # end 23 | # 24 | # @api private 25 | module Update 26 | extend ActiveSupport::Concern 27 | 28 | included do 29 | attr_accessor :__update_hook 30 | 31 | # Since rails 4.0 :update_record is used for actual updates 32 | # Since rails 4.0.x and 4.1.x (i really don't know which is x) :_update_record is used 33 | method_name = [:update_record, :_update_record].detect { |m| private_method_defined?(m) } || :update 34 | 35 | alias_method :update_without_hook, method_name 36 | alias_method method_name, :update_with_hook 37 | end 38 | 39 | def hook_update 40 | self.__update_hook = UpdateManager.new(self) 41 | yield __update_hook 42 | ensure 43 | self.__update_hook = nil 44 | end 45 | 46 | private 47 | def update_with_hook(*args) 48 | if __update_hook 49 | __update_hook.update! 50 | else 51 | update_without_hook(*args) 52 | end 53 | end 54 | 55 | class UpdateManager 56 | attr_reader :record 57 | attr_accessor :scope, :values 58 | 59 | def initialize(record) 60 | @record = record 61 | @values = {} 62 | end 63 | 64 | def update! 65 | scope.update_all(to_sql) 66 | record.reload 67 | end 68 | 69 | private 70 | def to_sql 71 | values.keys.map do |attr| 72 | name = attr.is_a?(Arel::Attributes::Attribute) ? attr.name : attr.to_s 73 | 74 | quoted = record.class.connection.quote_column_name(name) 75 | "#{quoted} = (#{value_of(attr)})" 76 | end.join(', ') 77 | end 78 | 79 | def value_of(attr) 80 | value = values[attr] 81 | value.respond_to?(:to_sql) ? value.to_sql : record.class.connection.quote(value) 82 | end 83 | end # class CustomUpdate 84 | end # module Update 85 | end # module Hooks 86 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/instance_methods.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/node' 4 | require 'acts_as_ordered_tree/transaction/factory' 5 | require 'acts_as_ordered_tree/deprecate' 6 | 7 | module ActsAsOrderedTree 8 | module InstanceMethods 9 | extend Deprecate 10 | 11 | delegate :root?, 12 | :leaf?, 13 | :has_children?, 14 | :has_parent?, 15 | :first?, 16 | :last?, 17 | :is_descendant_of?, 18 | :is_or_is_descendant_of?, 19 | :is_ancestor_of?, 20 | :is_or_is_ancestor_of?, 21 | :to => :ordered_tree_node 22 | 23 | delegate :move_to_root, 24 | :move_higher, 25 | :move_left, 26 | :move_lower, 27 | :move_right, 28 | :move_to_above_of, 29 | :move_to_left_of, 30 | :move_to_bottom_of, 31 | :move_to_right_of, 32 | :move_to_child_of, 33 | :move_to_child_with_index, 34 | :move_to_child_with_position, 35 | :to => :ordered_tree_node 36 | 37 | delegate :ancestors, 38 | :self_and_ancestors, 39 | :descendants, 40 | :self_and_descendants, 41 | :root, 42 | :siblings, 43 | :self_and_siblings, 44 | :left_siblings, 45 | :higher_items, 46 | :left_sibling, 47 | :higher_item, 48 | :left_sibling=, 49 | :left_sibling_id=, 50 | :higher_item=, 51 | :higher_item_id=, 52 | :right_siblings, 53 | :lower_items, 54 | :right_sibling, 55 | :lower_item, 56 | :right_sibling=, 57 | :right_sibling_id=, 58 | :lower_item=, 59 | :lower_item_id=, 60 | :to => :ordered_tree_node 61 | 62 | delegate :level, :to => :ordered_tree_node 63 | 64 | # Returns ordered tree node - an object which maintains tree integrity. 65 | # WARNING: THIS METHOD IS NOT THREAD SAFE! 66 | # Though I'm not sure if it can cause any problems. 67 | # 68 | # @return [ActsAsOrderedTree::Node] 69 | def ordered_tree_node 70 | @ordered_tree_node ||= ActsAsOrderedTree::Node.new(self) 71 | end 72 | 73 | # Insert the item at the given position (defaults to the top position of 1). 74 | # +acts_as_list+ compatibility 75 | # 76 | # @deprecated 77 | deprecated_method :insert_at, :move_to_child_with_position do |position = 1| 78 | move_to_child_with_position(parent, position) 79 | end 80 | 81 | # Returns +true+ if it is possible to move node to left/right/child of +target+. 82 | # 83 | # @param [ActiveRecord::Base] target 84 | # @deprecated 85 | deprecated_method :move_possible? do |target| 86 | ordered_tree_node.same_scope?(target) && 87 | !ordered_tree_node.is_or_is_ancestor_of?(target) 88 | end 89 | 90 | # Returns true if node contains any children. 91 | # 92 | # @deprecated 93 | deprecated_method :branch?, :has_children? 94 | 95 | # Returns true is node is not a root node. 96 | # 97 | # @deprecated 98 | deprecated_method :child?, :has_parent? 99 | 100 | private 101 | # Around callback that starts ActsAsOrderedTree::Transaction 102 | def save_ordered_tree_node(&block) 103 | Transaction::Factory.create(ordered_tree_node).start(&block) 104 | end 105 | 106 | # Around callback that starts ActsAsOrderedTree::Transaction 107 | def destroy_ordered_tree_node(&block) 108 | Transaction::Factory.create(ordered_tree_node, true).start(&block) 109 | end 110 | end # module InstanceMethods 111 | end # module ActsAsOrderedTree 112 | -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/iterators/arranger.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | module ActsAsOrderedTree 4 | module Iterators 5 | # @api private 6 | class Arranger 7 | include Enumerable 8 | 9 | delegate :each, :to => :arrange 10 | 11 | def initialize(collection) 12 | @collection = collection 13 | @cache = Hash.new 14 | end 15 | 16 | def arrange 17 | @collection.each_with_object(Hash.new) do |node, result| 18 | @cache[node.id] ||= node 19 | 20 | insertion_point = result 21 | 22 | ancestors(node).each { |a| insertion_point = (insertion_point[a] ||= {}) } 23 | 24 | insertion_point[node] = {} 25 | end 26 | end 27 | 28 | private 29 | def ancestors(node) 30 | parent = @cache[node.ordered_tree_node.parent_id] 31 | parent ? ancestors(parent) + [parent] : [] 32 | end 33 | end # class Arranger 34 | end # module Iterators 35 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/iterators/level_calculator.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | module ActsAsOrderedTree 4 | module Iterators 5 | # @api private 6 | class LevelCalculator 7 | include Enumerable 8 | 9 | def initialize(collection) 10 | @collection = collection 11 | @level = nil # minimal nodes level (first item level) 12 | end 13 | 14 | def each(&block) 15 | return to_enum unless block_given? 16 | 17 | if @collection.klass.ordered_tree.columns.depth? 18 | each_with_cached_level(&block) 19 | else 20 | each_without_cached_level(&block) 21 | end 22 | end 23 | 24 | private 25 | def each_with_cached_level 26 | @collection.each { |node| yield node, node.level } 27 | end 28 | 29 | def each_without_cached_level 30 | path = [] 31 | 32 | @collection.each do |node| 33 | parent_id = node.ordered_tree_node.parent_id 34 | 35 | @level ||= node.level 36 | path << parent_id if path.empty? 37 | 38 | if parent_id != path.last 39 | # parent changed 40 | if path.include?(parent_id) # ascend 41 | path.pop while path.last != parent_id 42 | else # descend 43 | path << parent_id 44 | end 45 | end 46 | 47 | yield node, @level + path.length - 1 48 | end 49 | end 50 | end # class LevelCalculator 51 | end 52 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/iterators/orphans_pruner.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | module ActsAsOrderedTree 4 | module Iterators 5 | # @api private 6 | class OrphansPruner 7 | include Enumerable 8 | 9 | def initialize(collection) 10 | @collection = collection 11 | @cache = Hash.new 12 | @level = nil # minimal node level 13 | end 14 | 15 | def each 16 | return to_enum unless block_given? 17 | 18 | prepare if @cache.empty? 19 | 20 | @collection.each do |node| 21 | if orphan?(node) 22 | discard(node.id) 23 | else 24 | yield node 25 | end 26 | end 27 | end 28 | 29 | private 30 | def orphan?(node) 31 | !has_parent?(node) && node.level > @level 32 | end 33 | 34 | def has_parent?(node) 35 | @cache.key?(node.ordered_tree_node.parent_id) 36 | end 37 | 38 | def prepare 39 | @collection.each do |node| 40 | @cache[node.id] = [] 41 | 42 | if has_parent?(node) 43 | @cache[node.ordered_tree_node.parent_id] << node.id 44 | else 45 | @level = [@level, node.level].compact.min 46 | end 47 | end 48 | end 49 | 50 | def discard(id) 51 | if @cache.key?(id) 52 | @cache[id].each { |k| discard(k) } 53 | @cache.delete(id) 54 | end 55 | end 56 | end # class OrphansPruner 57 | end # module Iterators 58 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/node.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/node/attributes' 4 | require 'acts_as_ordered_tree/node/movements' 5 | require 'acts_as_ordered_tree/node/predicates' 6 | require 'acts_as_ordered_tree/node/reloading' 7 | require 'acts_as_ordered_tree/node/siblings' 8 | require 'acts_as_ordered_tree/node/traversals' 9 | 10 | module ActsAsOrderedTree 11 | # ActsAsOrderedTree::Node takes care of tree integrity when record is saved 12 | # via usual ActiveRecord mechanism 13 | class Node 14 | include Attributes 15 | include Movements 16 | include Predicates 17 | include Reloading 18 | include Siblings 19 | include Traversals 20 | 21 | # @attr_reader [ActiveRecord::Base] original AR record, created, updated or destroyed 22 | attr_reader :record 23 | 24 | delegate :id, :parent, :children, :==, :to => :record 25 | 26 | def initialize(record) 27 | @record = record 28 | end 29 | 30 | # Returns scope to which record should be applied 31 | def scope 32 | if tree.columns.scope? 33 | tree.base_class.where Hash[tree.columns.scope.map { |column| [column, record[column]] }] 34 | else 35 | tree.base_class.where(nil) 36 | end 37 | end 38 | 39 | # Convert node to AR::Relation 40 | # 41 | # @return [ActiveRecord::Relation] 42 | def to_relation 43 | scope.where(tree.columns.id => id) 44 | end 45 | 46 | # @return [ActsAsOrderedTree::Tree] 47 | def tree 48 | record.class.ordered_tree 49 | end 50 | 51 | # Returns node level value (0 for root) 52 | # 53 | # @return [Fixnum] 54 | def level 55 | case 56 | when root? then 0 57 | when depth_column_could_be_used? then depth 58 | when parent_association_loaded? then parent.level + 1 59 | # @todo move it adapters 60 | else ancestors.size 61 | end 62 | end 63 | 64 | private 65 | # @return [Arel::Table] 66 | def table 67 | record.class.arel_table 68 | end 69 | 70 | def depth_column_could_be_used? 71 | tree.columns.depth? && record.persisted? && !parent_id_changed? && depth? 72 | end 73 | 74 | def parent_association_loaded? 75 | record.association(:parent).loaded? 76 | end 77 | end # class Node 78 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/node/attributes.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'active_support/concern' 4 | 5 | module ActsAsOrderedTree 6 | class Node 7 | # This module when included creates accessor to record's attributes that related to tree structure 8 | # 9 | # @example 10 | # class Node 11 | # include Attributes 12 | # end 13 | # 14 | # node = Node.new(record) 15 | # node.parent_id # => record.parent_id 16 | # node.position # => record.position 17 | # node.position_was # => record.position_was 18 | # # etc. 19 | module Attributes 20 | extend ActiveSupport::Concern 21 | 22 | METHODS = ['', '?', ?=, '_was', '_changed?', %w(reset_ !)].freeze 23 | 24 | included do 25 | dynamic_attribute_accessor :position 26 | dynamic_attribute_accessor :parent_id, :parent 27 | dynamic_attribute_accessor :depth 28 | dynamic_attribute_accessor :counter_cache 29 | end 30 | 31 | module ClassMethods 32 | # Generates methods based on configurable record attributes 33 | # 34 | # @api private 35 | def dynamic_attribute_accessor(name, column_name_accessor = name) 36 | METHODS.each do |prefix, suffix| 37 | prefix, suffix = suffix, prefix unless suffix 38 | method_name = "#{prefix}#{name}#{suffix}" 39 | 40 | define_method method_name do |*args| 41 | record.send "#{prefix}#{tree.columns[column_name_accessor]}#{suffix}", *args 42 | end 43 | end 44 | end 45 | end # module ClassMethods 46 | end # module Attributes 47 | end # class Node 48 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/node/movement.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/persevering_transaction' 4 | 5 | module ActsAsOrderedTree 6 | class Node 7 | # @api private 8 | class Movement 9 | attr_reader :node, :options 10 | 11 | delegate :record, :position, :position=, :to => :node 12 | 13 | def initialize(node, target = nil, options = {}, &block) 14 | @node, @options, @block = node, options, block 15 | @_target = target 16 | end 17 | 18 | def parent=(id_or_record) 19 | if id_or_record.is_a?(ActiveRecord::Base) 20 | record.parent = id_or_record 21 | else 22 | node.parent_id = id_or_record 23 | end 24 | end 25 | 26 | def start 27 | transaction do 28 | record.reload 29 | 30 | @block[self] if @block 31 | 32 | record.save 33 | end 34 | end 35 | 36 | def target 37 | return @target if defined?(@target) 38 | 39 | # load target 40 | @target = case @_target 41 | when ActiveRecord::Base, nil 42 | @_target.lock! 43 | when nil 44 | nil 45 | else 46 | scope = node.scope.lock 47 | 48 | if options.fetch(:strict, false) 49 | scope.find(@_target) 50 | else 51 | scope.where(:id => @_target).first 52 | end 53 | end.try(:ordered_tree_node) 54 | end 55 | 56 | private 57 | def transaction(&block) 58 | PerseveringTransaction.new(record.class.connection).start(&block) 59 | end 60 | end # class Movement 61 | end # class Node 62 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/node/movements.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'active_support/core_ext/module/aliasing' 4 | 5 | require 'acts_as_ordered_tree/node/movement' 6 | 7 | module ActsAsOrderedTree 8 | class Node 9 | # This module provides node with movement functionality 10 | # 11 | # Methods: 12 | # * move_to_root 13 | # * move_left (move_higher) 14 | # * move_right (move_lower) 15 | # * move_to_left_of (move_to_above_of) 16 | # * move_to_right_of (move_to_bottom_of) 17 | # * move_to_child_of 18 | # * move_to_child_with_index 19 | # * move_to_child_with_position 20 | module Movements 21 | # Transform node into root node 22 | def move_to_root 23 | movement do |to| 24 | to.position = record.root? ? position : nil 25 | to.parent = nil 26 | end 27 | end 28 | 29 | # Swap node with higher sibling 30 | def move_higher 31 | movement { self.position -= 1 } 32 | end 33 | alias_method :move_left, :move_higher 34 | 35 | # Swap node with lower sibling 36 | def move_lower 37 | movement { self.position += 1 } 38 | end 39 | alias_method :move_right, :move_lower 40 | 41 | # Move node to above(left) of another node 42 | # 43 | # @param [ActiveRecord::Base, #to_i] node may be another record of ID 44 | def move_to_above_of(node) 45 | movement(node, :strict => true) do |to| 46 | self.right_sibling = to.target.record 47 | end 48 | end 49 | alias_method :move_to_left_of, :move_to_above_of 50 | 51 | # Move node to bottom (right) of another node 52 | # 53 | # @param [ActiveRecord::Base, #to_i] node may be another record of ID 54 | def move_to_bottom_of(node) 55 | movement(node, :strict => true) do |to| 56 | self.left_sibling = to.target.record 57 | end 58 | end 59 | alias_method :move_to_right_of, :move_to_bottom_of 60 | 61 | # Move node to child of another node 62 | # 63 | # @param [ActiveRecord::Base, #to_i] node may be another record of ID 64 | def move_to_child_of(node) 65 | if node 66 | movement(node) do |to| 67 | to.parent = node 68 | to.position = nil if parent_id_changed? 69 | end 70 | else 71 | move_to_root 72 | end 73 | end 74 | 75 | # Move node to child of another node with specified index (which may be negative) 76 | # 77 | # @param [ActiveRecord::Base, Fixnum] node 78 | # @param [#to_i] index 79 | def move_to_child_with_index(node, index) 80 | index = index.to_i 81 | 82 | if index >= 0 83 | move_to_child_with_position node, index + 1 84 | elsif node 85 | movement(node, :strict => true) do |to| 86 | to.parent = node 87 | to.position = to.target.children.size + index + 1 88 | end 89 | else 90 | move_to_child_with_position nil, scope.roots.size + index + 1 91 | end 92 | end 93 | 94 | # Move node to child of another node with specified position 95 | # 96 | # @param [ActiveRecord::Base, Fixnum] node 97 | # @param [Integer, nil] position 98 | def move_to_child_with_position(node, position) 99 | movement(node) do |to| 100 | to.parent = node 101 | to.position = position 102 | end 103 | end 104 | 105 | private 106 | def movement(target = nil, options = {}, &block) 107 | Movement.new(self, target, options, &block).start 108 | end 109 | end # module Movements 110 | end # class Node 111 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/node/predicates.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | module ActsAsOrderedTree 4 | class Node 5 | module Predicates 6 | # Returns true if this is a root node. 7 | def root? 8 | !parent_id? 9 | end 10 | 11 | # Returns true if this is the end of a branch. 12 | def leaf? 13 | record.persisted? && if children.loaded? || tree.columns.counter_cache? 14 | # no SQL-queries here 15 | children.empty? 16 | else 17 | !children.exists? 18 | end 19 | end 20 | 21 | # Returns true if node contains any children. 22 | def has_children? 23 | !leaf? 24 | end 25 | 26 | # Returns true is node is not a root node. 27 | def has_parent? 28 | !root? 29 | end 30 | 31 | # Returns true if current node is descendant of +other+ node. 32 | # 33 | # @param [ActiveRecord::Base] other 34 | def is_descendant_of?(other) 35 | same_scope?(other) && 36 | ancestors.include?(other) && 37 | ancestors.all? { |x| x.ordered_tree_node.same_scope?(record) } 38 | end 39 | 40 | # Returns true if current node is equal to +other+ node or is descendant of +other+ node. 41 | # 42 | # @param [ActiveRecord::Base] other 43 | def is_or_is_descendant_of?(other) 44 | record == other || is_descendant_of?(other) 45 | end 46 | 47 | # Returns true if current node is ancestor of +other+ node. 48 | # 49 | # @param [ActiveRecord::Base] other 50 | def is_ancestor_of?(other) 51 | same_scope?(other) && other.is_descendant_of?(record) 52 | end 53 | 54 | # Returns true if current node is equal to +other+ node or is ancestor of +other+ node. 55 | # 56 | # @param [ActiveRecord::Base] other 57 | def is_or_is_ancestor_of?(other) 58 | same_scope?(other) && other.is_or_is_descendant_of?(record) 59 | end 60 | 61 | # Return +true+ if this object is the first in the list. 62 | def first? 63 | position <= 1 64 | end 65 | 66 | # Return +true+ if this object is the last in the list. 67 | def last? 68 | if tree.columns.counter_cache? && parent 69 | parent.children.size == position 70 | else 71 | !right_sibling 72 | end 73 | end 74 | 75 | # Check if other node is in the same scope. 76 | # 77 | # @api private 78 | def same_scope?(other) 79 | same_kind?(other) && tree.columns.scope.all? do |attr| 80 | record[attr] == other[attr] 81 | end 82 | end 83 | 84 | # Check if other node has the same parent 85 | # 86 | # @api private 87 | def same_parent?(other) 88 | same_scope?(other) && parent_id == other.ordered_tree_node.parent_id 89 | end 90 | 91 | private 92 | # Check if other node belongs to same class hierarchy. 93 | def same_kind?(other) 94 | other.ordered_tree && other.ordered_tree.base_class == tree.base_class 95 | end 96 | end # module Predicates 97 | end # class Node 98 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/node/reloading.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/compatibility' 4 | 5 | module ActsAsOrderedTree 6 | class Node 7 | module Reloading 8 | Compatibility.version '< 4.0.0' do 9 | # Reloads node's attributes related to tree structure 10 | def reload(options = {}) 11 | record.reload(options.merge(:select => tree_columns)) 12 | end 13 | end 14 | 15 | Compatibility.version '>= 4.0.0' do 16 | # Reloads node's attributes related to tree structure 17 | def reload(options = {}) 18 | record.association_cache.delete(:parent) 19 | record.association_cache.delete(:children) 20 | 21 | fresh_object = reload_scope(options).find(record.id) 22 | 23 | fresh_object.attributes.each_pair do |key, value| 24 | record[key] = value 25 | end 26 | 27 | record.instance_eval do 28 | # @attributes.update(fresh_object.instance_variable_get(:@attributes)) 29 | @attributes_cache = {} 30 | end 31 | 32 | record 33 | end 34 | 35 | private 36 | def reload_scope(options) 37 | options ||= {} 38 | lock_value = options.fetch(:lock, false) 39 | record.class.unscoped.select(tree_columns).lock(lock_value) 40 | end 41 | end 42 | 43 | private 44 | def tree_columns 45 | tree.columns.to_a 46 | end 47 | end 48 | end 49 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/node/siblings.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/node/predicates' 4 | 5 | module ActsAsOrderedTree 6 | class Node 7 | module Siblings 8 | include Predicates 9 | 10 | # Returns collection of all children of the parent, including self 11 | # 12 | # @return [ActiveRecord::Relation] 13 | def self_and_siblings 14 | scope.where( tree.columns.parent => parent_id ).preorder 15 | end 16 | 17 | # Returns collection of all children of the parent, except self 18 | # 19 | # @return [ActiveRecord::Relation] 20 | def siblings 21 | self_and_siblings.where( table[tree.columns.id].not_eq(id) ) 22 | end 23 | 24 | # Returns siblings lying to the left of (upper than) current node. 25 | # 26 | # @return [ActiveRecord::Relation] 27 | def left_siblings 28 | siblings.where( table[tree.columns.position].lteq(position) ) 29 | end 30 | alias :higher_items :left_siblings 31 | 32 | # Returns a left (upper) sibling of node. 33 | # 34 | # @return [ActiveRecord::Base, nil] 35 | def left_sibling 36 | higher_items.last 37 | end 38 | alias :higher_item :left_sibling 39 | 40 | # Set node new left (upper) sibling. 41 | # Just changes node's parent_id and position attributes. 42 | # 43 | # @param [ActiveRecord::Base] node new left sibling 44 | # @raise [ActiveRecord::AssociationTypeMismatch] if +node+ class does not 45 | # match current node class. 46 | def left_sibling=(node) 47 | return node if record == node 48 | 49 | to = validate_sibling!(node) 50 | 51 | self.position = higher_than?(node) ? to.position : to.position + 1 52 | self.parent_id = to.parent_id 53 | 54 | node 55 | end 56 | alias :higher_item= :left_sibling= 57 | 58 | # Set node new left sibling by its ID. 59 | # Changes node's parent_id and position. 60 | # 61 | # @param [Fixnum] id new left sibling ID 62 | # @raise [ActiveRecord::RecordNotFound] if given +id+ was not found 63 | def left_sibling_id=(id) 64 | assign_sibling_by_id(id, :left) 65 | end 66 | alias :higher_item_id= :left_sibling_id= 67 | 68 | # Returns siblings lying to the right of (lower than) current node. 69 | # 70 | # @return [ActiveRecord::Relation] 71 | def right_siblings 72 | siblings.where( table[tree.columns.position].gteq(position) ) 73 | end 74 | alias :lower_items :right_siblings 75 | 76 | # Returns a right (lower) sibling of the node 77 | # 78 | # @return [ActiveRecord::Base, nil] 79 | def right_sibling 80 | right_siblings.first 81 | end 82 | alias :lower_item :right_sibling 83 | 84 | # Set node new right (lower) sibling. 85 | # Just changes node's parent_id and position attributes. 86 | # 87 | # @param [ActiveRecord::Base] node new right sibling 88 | # @raise [ActiveRecord::AssociationTypeMismatch] if +node+ class does not 89 | # match current node class. 90 | def right_sibling=(node) 91 | to = validate_sibling!(node) 92 | 93 | self.position = higher_than?(node) ? to.position - 1 : to.position 94 | self.parent_id = to.parent_id 95 | 96 | node 97 | end 98 | alias :lower_item= :right_sibling= 99 | 100 | # Set node new right sibling by its ID. 101 | # Changes node's parent_id and position. 102 | # 103 | # @param [Fixnum] id new right sibling ID 104 | # @raise [ActiveRecord::RecordNotFound] if given +id+ was not found 105 | def right_sibling_id=(id) 106 | assign_sibling_by_id(id, :right) 107 | end 108 | alias :lower_item_id= :right_sibling_id= 109 | 110 | private 111 | def higher_than?(other) 112 | same_parent?(other) && position < other.ordered_tree_node.position 113 | end 114 | 115 | # Raises exception if +other+ is kind of wrong class 116 | # 117 | # @return [ActsAsOrderedTree::Node] 118 | def validate_sibling!(other) 119 | unless other.is_a?(tree.base_class) 120 | message = "#{tree.base_class.name} expected, got #{other.class.name}" 121 | raise ActiveRecord::AssociationTypeMismatch, message 122 | end 123 | 124 | other.ordered_tree_node 125 | end 126 | 127 | # @api private 128 | def assign_sibling_by_id(id, position) 129 | node = tree.base_class.find(id) 130 | 131 | case position 132 | when :left, :higher then self.left_sibling = node 133 | when :right, :lower then self.right_sibling = node 134 | else raise RuntimeError, 'Unknown sibling position' 135 | end 136 | end 137 | end # module Siblings 138 | end # class Node 139 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/node/traversals.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/relation/arrangeable' 4 | require 'acts_as_ordered_tree/relation/iterable' 5 | 6 | module ActsAsOrderedTree 7 | class Node 8 | module Traversals 9 | # Returns relation that contains all node's parents, starting from root. 10 | # 11 | # @return [ActiveRecord::Relation] 12 | def ancestors 13 | iterable tree.adapter.ancestors(record) 14 | end 15 | 16 | # Returns relation that containt all node's parents 17 | # and node itself, starting from root. 18 | # 19 | # @return [ActiveRecord::Relation] 20 | def self_and_ancestors 21 | iterable tree.adapter.self_and_ancestors(record) 22 | end 23 | 24 | # Returns collection of all node's children including their nested children. 25 | # 26 | # @return [ActiveRecord::Relation] 27 | def descendants 28 | iterable tree.adapter.descendants(record) 29 | end 30 | 31 | # Returns collection of all node's children including their 32 | # nested children, and node itself. 33 | # 34 | # @return [ActiveRecord::Relation] 35 | def self_and_descendants 36 | iterable tree.adapter.self_and_descendants(record) 37 | end 38 | 39 | # Returns very first ancestor of current node. If current node is root, 40 | # then method returns node itself. 41 | # 42 | # @return [ActiveRecord::Base] 43 | def root 44 | root? ? record : ancestors.first 45 | end 46 | 47 | private 48 | def iterable(scope) 49 | scope.extending(Relation::Arrangeable, Relation::Iterable) 50 | end 51 | end # module Traversals 52 | end # module Node 53 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/persevering_transaction.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | module ActsAsOrderedTree 4 | class PerseveringTransaction 5 | module State 6 | # Generate helper methods for given +state+. 7 | # AR adapter calls :committed! and :rolledback! methods 8 | # 9 | # @api private 10 | def state_method(state) 11 | define_method "#{state}!" do |*| 12 | @state = state 13 | end 14 | 15 | define_method "#{state}?" do 16 | @state == state 17 | end 18 | end 19 | end 20 | extend State 21 | 22 | # Which errors should be treated as deadlocks 23 | DEADLOCK_MESSAGES = Regexp.new [ 24 | 'Deadlock found when trying to get lock', 25 | 'Lock wait timeout exceeded', 26 | 'deadlock detected', 27 | 'database is locked' 28 | ].join(?|).freeze 29 | # How many times we should retry transaction 30 | RETRY_COUNT = 10 31 | 32 | attr_reader :connection, :attempts 33 | delegate :logger, :to => :connection 34 | 35 | state_method :committed 36 | state_method :rolledback 37 | 38 | def initialize(connection) 39 | @connection = connection 40 | @attempts = 0 41 | @callbacks = [] 42 | @state = nil 43 | end 44 | 45 | # Starts persevering transaction 46 | def start(&block) 47 | @attempts += 1 48 | 49 | with_transaction_state(&block) 50 | rescue ActiveRecord::StatementInvalid => error 51 | raise unless connection.open_transactions.zero? 52 | raise unless error.message =~ DEADLOCK_MESSAGES 53 | raise if attempts >= RETRY_COUNT 54 | 55 | logger.info "Deadlock detected on attempt #{attempts}, restarting transaction" 56 | 57 | pause and retry 58 | end 59 | 60 | # Execute given +block+ when after transaction _real_ commit 61 | def after_commit(&block) 62 | @callbacks << block if block_given? 63 | end 64 | 65 | # This method is called by AR adapter 66 | # @api private 67 | def has_transactional_callbacks? 68 | true 69 | end 70 | 71 | # Marks this transaction as committed and executes its commit callbacks 72 | # @api private 73 | def committed_with_callbacks! 74 | committed_without_callbacks! 75 | @callbacks.each { |callback| callback.call } 76 | end 77 | alias_method_chain :committed!, :callbacks 78 | 79 | private 80 | def pause 81 | sleep(rand(attempts) * 0.1) 82 | end 83 | 84 | # Runs real transaction and remembers its state 85 | def with_transaction_state 86 | connection.transaction do 87 | connection.add_transaction_record(self) 88 | 89 | yield 90 | end 91 | end 92 | end # class PerseveringTransaction 93 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/position.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | module ActsAsOrderedTree 4 | # Position structure aggregates knowledge about node's position in the tree 5 | # 6 | # @api private 7 | class Position 8 | # This class represents node position change 9 | # 10 | # @api private 11 | class Transition 12 | # @return [ActsAsOrderedTree::Position] 13 | attr_reader :from 14 | 15 | # @return [ActsAsOrderedTree::Position] 16 | attr_reader :to 17 | 18 | # @param [ActsAsOrderedTree::Position] from 19 | # @param [ActsAsOrderedTree::Position] to 20 | def initialize(from, to) 21 | @from, @to = from, to 22 | end 23 | 24 | def changed? 25 | from != to 26 | end 27 | 28 | def reorder? 29 | changed? && from.parent_id == to.parent_id 30 | end 31 | 32 | def movement? 33 | changed? && from.parent_id != to.parent_id 34 | end 35 | 36 | def level_changed? 37 | from.depth != to.depth 38 | end 39 | 40 | def update_counters 41 | if movement? 42 | from.decrement_counter 43 | to.increment_counter 44 | end 45 | end 46 | end 47 | 48 | attr_reader :node, :position 49 | attr_accessor :parent_id 50 | 51 | delegate :record, :to => :node 52 | 53 | # @param [ActsAsOrderedTree::Node] node 54 | # @param [Integer] parent_id 55 | # @param [Integer] position 56 | def initialize(node, parent_id, position) 57 | @node, @parent_id, self.position = node, parent_id, position 58 | end 59 | 60 | # attr_writer with coercion to [nil or Integer] 61 | def position=(value) 62 | @position = value.presence && value.to_i 63 | end 64 | 65 | def klass 66 | record.class 67 | end 68 | 69 | def parent 70 | return @parent if defined?(@parent) 71 | 72 | @parent = parent_id ? fetch_parent : nil 73 | end 74 | 75 | def parent? 76 | parent.present? 77 | end 78 | 79 | def root? 80 | parent.blank? 81 | end 82 | 83 | def depth 84 | @depth ||= parent ? parent.level + 1 : 0 85 | end 86 | 87 | # Locks current position. Technically, it means that pessimistic 88 | # lock will be obtained on parent node (or all root nodes if position is root) 89 | def lock! 90 | if parent 91 | parent.lock! 92 | else 93 | siblings.lock.reload 94 | end 95 | end 96 | 97 | # predicate 98 | def position? 99 | position.present? 100 | end 101 | 102 | # Returns all nodes within given position 103 | def siblings 104 | node.scope.where(klass.ordered_tree.columns.parent => parent_id) 105 | end 106 | 107 | # Returns all nodes that are lower than current position 108 | def lower 109 | position? ? 110 | siblings.where(klass.arel_table[klass.ordered_tree.columns.position].gteq(position)) : 111 | siblings 112 | end 113 | 114 | def increment_counter 115 | update_counter(:increment_counter) 116 | end 117 | 118 | def decrement_counter 119 | update_counter(:decrement_counter) 120 | end 121 | 122 | # @param [ActsAsOrderedTree::Node::Position] other 123 | def ==(other) 124 | other.is_a?(self.class) && 125 | other.record == record && 126 | other.parent_id == parent_id && 127 | other.position == position 128 | end 129 | 130 | private 131 | def fetch_parent 132 | parent_id == record[klass.ordered_tree.columns.parent] ? 133 | record.parent : 134 | node.scope.find(parent_id) 135 | end 136 | 137 | def update_counter(method) 138 | if (column = klass.ordered_tree.columns.counter_cache) && parent_id 139 | klass.send(method, column, parent_id) 140 | end 141 | end 142 | end # class Position 143 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/relation/arrangeable.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/iterators/arranger' 4 | require 'acts_as_ordered_tree/iterators/orphans_pruner' 5 | 6 | module ActsAsOrderedTree 7 | module Relation 8 | # This AR::Relation extension allows to arrange collection into 9 | # Hash of nested Hashes 10 | module Arrangeable 11 | # Arrange associated collection into a nested hash of the form 12 | # {node => children}, where children = {} if the node has no children. 13 | # 14 | # It is possible to discard orphaned nodes (nodes which don't have 15 | # corresponding parent node in this collection) by passing `:orphans => :discard` 16 | # as option. 17 | # 18 | # @param [Hash] options 19 | # @option options [:discard, nil] :orphans 20 | # @return [Hash Hash>] 21 | def arrange(options = {}) 22 | collection = self 23 | 24 | if options && options[:orphans] == :discard 25 | collection = Iterators::OrphansPruner.new(self) 26 | end 27 | 28 | @arranger ||= Iterators::Arranger.new(collection) 29 | @arranger.arrange 30 | end 31 | end 32 | end 33 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/relation/iterable.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/iterators/level_calculator' 4 | require 'acts_as_ordered_tree/iterators/orphans_pruner' 5 | 6 | module ActsAsOrderedTree 7 | module Relation 8 | module Iterable 9 | # Iterates over tree elements and determines the current level in the tree. 10 | # Only accepts default ordering, no orphans allowed (they considered as root elements). 11 | # This method is efficient on trees that don't cache level. 12 | # 13 | # @example 14 | # node.descendants.each_with_level do |descendant, level| 15 | # end 16 | # 17 | # @return [Enumerator] if block is not given 18 | def each_with_level(&block) 19 | Iterators::LevelCalculator.new(self).each(&block) 20 | end 21 | 22 | # Iterates over tree elements but discards any orphaned nodes (e.g. nodes 23 | # which have a parent, but parent isn't in current collection). 24 | # 25 | # @example Collection with orphaned nodes 26 | # # Assume we have following tree: 27 | # # root 1 28 | # # child 1 29 | # # root 2 30 | # # child 2 31 | # 32 | # MyModel.where('id != ?', root_1.id).extending(Iterable).each_without_orphans.to_a 33 | # # => [root_2, child_2] 34 | # 35 | # @return [Enumerator] if block is not given 36 | def each_without_orphans(&block) 37 | Iterators::OrphansPruner.new(self).each(&block) 38 | end 39 | end # module Iterable 40 | end # module Relation 41 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/relation/preloaded.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | module ActsAsOrderedTree 4 | module Relation 5 | # AR::Relation extension which adds ability to explicitly set records 6 | # 7 | # @example 8 | # records = MyModel.where(:parent_id => nil).to_a 9 | # relation = MyModel.where(:parent_id => nil). 10 | # extending(ActsAsOrderedTree::Relation::Preloaded). 11 | # records(records) 12 | # relation.to_a.should be records 13 | module Preloaded 14 | def records(records) 15 | @loaded = false 16 | @records = records 17 | 18 | build_where! 19 | 20 | @loaded = true 21 | 22 | self 23 | end 24 | 25 | # Reverse the existing order of records on the relation. 26 | def reverse_order 27 | (respond_to?(:spawn) ? spawn : clone).records(@records.reverse) 28 | end 29 | 30 | def reverse_order! 31 | @records = @records.reverse 32 | 33 | self 34 | end 35 | 36 | # Extending relation is not really intrusive operation, so we can save preloaded records 37 | def extending(*) 38 | super.tap { |relation| relation.records(@records) if loaded? } 39 | end 40 | 41 | private 42 | def record_ids 43 | @records.map { |r| r.id if r }.compact 44 | end 45 | 46 | def build_where! 47 | self.where_values = build_where(:id => record_ids) 48 | end 49 | end # module Preloaded 50 | end # module Relation 51 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/transaction/base.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/persevering_transaction' 4 | require 'acts_as_ordered_tree/transaction/callbacks' 5 | 6 | module ActsAsOrderedTree 7 | module Transaction 8 | # Persevering transaction, which restarts on deadlock 9 | # 10 | # Here we have a tree of possible transaction types: 11 | # 12 | # Base (abstract) 13 | # Save (abstract) 14 | # Create 15 | # Update (abstract) 16 | # Move 17 | # Reorder 18 | # Destroy 19 | # 20 | # @api private 21 | class Base 22 | extend Callbacks 23 | 24 | attr_reader :node 25 | 26 | delegate :record, :tree, :to => :node 27 | delegate :connection, :to => :klass 28 | 29 | # @param [ActsAsOrderedTree::Node] node 30 | def initialize(node) 31 | @node = node 32 | end 33 | 34 | # Start persevering transaction, which will restart on deadlock 35 | def start(&block) 36 | transaction.start do 37 | run_callbacks(:transaction, &block) 38 | end 39 | end 40 | 41 | protected 42 | def klass 43 | record.class 44 | end 45 | 46 | # Returns underlying transaction object 47 | def transaction 48 | @transaction ||= PerseveringTransaction.new(connection) 49 | end 50 | 51 | # Trigger tree callback (before_add, after_add, before_remove, after_remove) 52 | def trigger_callback(kind, owner) 53 | tree.callbacks.send(kind, owner, record) if owner.present? 54 | end 55 | end 56 | end 57 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/transaction/callbacks.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/callbacks' 2 | 3 | module ActsAsOrderedTree 4 | module Transaction 5 | module Callbacks 6 | def self.extended(base) 7 | base.send(:include, ActiveSupport::Callbacks) 8 | base.define_callbacks :transaction 9 | end 10 | 11 | def before(filter, *options, &block) 12 | set_callback :transaction, :before, filter, *options, &block 13 | end 14 | 15 | def after(filter, *options, &block) 16 | set_callback :transaction, :after, filter, *options, &block 17 | end 18 | 19 | def around(filter, *options, &block) 20 | set_callback :transaction, :around, filter, *options, &block 21 | end 22 | 23 | # This method should be called in concrete transaction classes to prevent 24 | # race conditions in multi-threaded environments. 25 | # 26 | # @api private 27 | def finalize 28 | finalize_callbacks :transaction 29 | end 30 | 31 | private 32 | Compatibility.version '< 3.2.0' do 33 | def finalize_callbacks(kind) 34 | __define_runner(kind) 35 | end 36 | end 37 | 38 | Compatibility.version '>= 3.2.0', '< 4.0.0' do 39 | def finalize_callbacks(kind) 40 | __reset_runner(kind) 41 | 42 | object = allocate 43 | 44 | name = __callback_runner_name(nil, kind) 45 | unless object.respond_to?(name, true) 46 | str = object.send("_#{kind}_callbacks").compile(nil, object) 47 | class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 48 | def #{name}() #{str} end 49 | protected :#{name} 50 | RUBY_EVAL 51 | end 52 | end 53 | end 54 | 55 | Compatibility.version '>= 4.0.0', '< 4.1.0' do 56 | def finalize_callbacks(kind) 57 | __define_callbacks(kind, allocate) 58 | end 59 | end 60 | 61 | # Rails 4.1 is thread safe 62 | Compatibility.version '>= 4.1.0' do 63 | def finalize_callbacks(kind) end 64 | end 65 | end # module Callbacks 66 | end # module Transaction 67 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/transaction/create.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/transaction/save' 4 | require 'acts_as_ordered_tree/transaction/dsl' 5 | 6 | module ActsAsOrderedTree 7 | module Transaction 8 | # Create transaction (for new records only) 9 | # @api private 10 | class Create < Save 11 | include DSL 12 | 13 | before :push_to_bottom_after_commit, :if => 'push_to_bottom? && to.root?' 14 | before :set_counter_cache, :if => 'tree.columns.counter_cache?' 15 | before :increment_lower_positions, :unless => :push_to_bottom? 16 | before 'trigger_callback(:before_add, to.parent)' 17 | 18 | after 'to.increment_counter' 19 | after 'node.reload' 20 | after 'trigger_callback(:after_add, to.parent)' 21 | 22 | finalize 23 | 24 | private 25 | def set_counter_cache 26 | record[tree.columns.counter_cache] = 0 27 | end 28 | 29 | def increment_lower_positions 30 | to.lower.update_all set position => position + 1 31 | end 32 | 33 | # If record was created as root there is a chance that position will collide, 34 | # but this callback will force record to placed at the bottom of tree. 35 | # 36 | # Yep, concurrency is a tough thing. 37 | # 38 | # @see https://github.com/take-five/acts_as_ordered_tree/issues/24 39 | def push_to_bottom_after_commit 40 | transaction.after_commit do 41 | connection.logger.debug { "Forcing new record (id=#{record.id}, position=#{node.position}) to be placed to bottom" } 42 | 43 | connection.transaction do 44 | # lock new siblings 45 | to.siblings.lock.reload 46 | 47 | if positions_collided? 48 | update_created set position => siblings.select(coalesce(max(position), 0) + 1) 49 | end 50 | end 51 | end 52 | end 53 | 54 | # Checks if there is +position_column+ collision within new parent 55 | def positions_collided? 56 | to.siblings.where(id.not_eq(record.id).and(position.eq(to.position))).exists? 57 | end 58 | 59 | def update_created(*args) 60 | to.siblings.where(id.eq(record.id)).update_all(*args) 61 | end 62 | 63 | def siblings 64 | to.siblings.where(id.not_eq(record.id)) 65 | end 66 | end # class Create 67 | end # module Transaction 68 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/transaction/destroy.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/transaction/base' 4 | require 'acts_as_ordered_tree/transaction/dsl' 5 | 6 | module ActsAsOrderedTree 7 | module Transaction 8 | class Destroy < Base 9 | include DSL 10 | 11 | attr_reader :from 12 | 13 | before 'trigger_callback(:before_remove, from.parent)' 14 | 15 | after :decrement_lower_positions 16 | after 'from.decrement_counter' 17 | after 'trigger_callback(:after_remove, from.parent)' 18 | 19 | finalize 20 | 21 | # @param [ActsAsOrderedTree::Node] node 22 | # @param [ActsAsOrderedTree::Position] from from which position given +node+ is destroyed 23 | def initialize(node, from) 24 | super(node) 25 | @from = from 26 | end 27 | 28 | private 29 | def decrement_lower_positions 30 | from.lower.update_all set position => position - 1 31 | end 32 | end 33 | end 34 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/transaction/dsl.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'active_support/core_ext/string/inflections' 4 | 5 | unless defined? Arel::Nodes::Case 6 | module Arel 7 | module Nodes 8 | # Case node 9 | # 10 | # @example 11 | # switch.when(table[:x].gt(1), table[:y]).else(table[:z]) 12 | # # CASE WHEN "table"."x" > 1 THEN "table"."y" ELSE "table"."z" END 13 | # switch.when(table[:x].gt(1)).then(table[:y]).else(table[:z]) 14 | class Case < Arel::Nodes::Node 15 | include Arel::OrderPredications 16 | include Arel::Predications 17 | 18 | attr_reader :conditions, :default 19 | 20 | def initialize 21 | @conditions = [] 22 | @default = nil 23 | end 24 | 25 | def when(condition, expression = nil) 26 | @conditions << When.new(condition, expression) 27 | self 28 | end 29 | 30 | def then(expression) 31 | @conditions.last.right = expression 32 | self 33 | end 34 | 35 | def else(expression) 36 | @default = Else.new(expression) 37 | self 38 | end 39 | end 40 | 41 | class When < Arel::Nodes::Binary 42 | end 43 | 44 | class Else < Arel::Nodes::Unary 45 | end 46 | end 47 | 48 | module Visitors 49 | class ToSql < Arel::Visitors::ToSql.superclass 50 | private 51 | def visit_Arel_Nodes_Case o, *a 52 | conditions = o.conditions.map { |x| visit x, *a }.join(' ') 53 | default = o.default && visit(o.default, *a) 54 | 55 | "CASE #{[conditions, default].compact.join(' ')} END" 56 | end 57 | 58 | def visit_Arel_Nodes_When o, *a 59 | "WHEN #{visit o.left, *a} THEN #{visit o.right, *a}" 60 | end 61 | 62 | def visit_Arel_Nodes_Else o, *a 63 | "ELSE #{visit o.expr, *a}" 64 | end 65 | 66 | if Arel::VERSION >= '6.0.0' 67 | def visit_Arel_Nodes_Case o, collector 68 | collector << 'CASE ' 69 | o.conditions.each do |x| 70 | visit x, collector 71 | collector << ' ' 72 | end 73 | if o.default 74 | visit o.default, collector 75 | collector << ' ' 76 | end 77 | collector << 'END' 78 | end 79 | 80 | def visit_Arel_Nodes_When o, collector 81 | collector << 'WHEN ' 82 | visit o.left, collector 83 | collector << ' THEN ' 84 | visit o.right, collector 85 | end 86 | 87 | def visit_Arel_Nodes_Else o, collector 88 | collector << 'ELSE' 89 | visit o.expr, collector 90 | end 91 | 92 | def visit_NilClass o, collector 93 | collector << 'NULL' 94 | end 95 | end 96 | end 97 | 98 | class DepthFirst < Arel::Visitors::Visitor 99 | def visit_Arel_Nodes_Case o, *a 100 | visit o.conditions, *a 101 | visit o.default, *a 102 | end 103 | alias :visit_Arel_Nodes_When :binary 104 | alias :visit_Arel_Nodes_Else :unary 105 | end 106 | end 107 | end 108 | end 109 | 110 | module ActsAsOrderedTree 111 | module Transaction 112 | # Simple DSL to generate complex UPDATE queries. 113 | # Requires +record+ method. 114 | # 115 | # @api private 116 | module DSL 117 | module Shortcuts 118 | INFIX_OPERATIONS = Hash[ 119 | :== => Arel::Nodes::Equality, 120 | :'!=' => Arel::Nodes::NotEqual, 121 | :> => Arel::Nodes::GreaterThan, 122 | :>= => Arel::Nodes::GreaterThanOrEqual, 123 | :< => Arel::Nodes::LessThan, 124 | :<= => Arel::Nodes::LessThanOrEqual, 125 | :=~ => Arel::Nodes::Matches, 126 | :'!~' => Arel::Nodes::DoesNotMatch, 127 | :| => Arel::Nodes::Or 128 | ] 129 | 130 | # generate subclasses and methods 131 | INFIX_OPERATIONS.each do |operator, klass| 132 | subclass = Class.new(klass) { include Shortcuts } 133 | const_set(klass.name.demodulize, subclass) 134 | INFIX_OPERATIONS[operator] = subclass 135 | 136 | define_method(operator) do |arg| 137 | subclass.new(self, arg) 138 | end 139 | end 140 | 141 | And = Class.new(Arel::Nodes::And) { include Shortcuts } 142 | 143 | def &(arg) 144 | And.new [self, arg] 145 | end 146 | end 147 | 148 | Attribute = Class.new(Arel::Attributes::Attribute) { include Shortcuts } 149 | SqlLiteral = Class.new(Arel::Nodes::SqlLiteral) { include Shortcuts } 150 | 151 | NamedFunction = Class.new(Arel::Nodes::NamedFunction) { 152 | include Shortcuts 153 | include Arel::Math 154 | } 155 | 156 | # Create Arel::Nodes::Case node 157 | def switch 158 | Arel::Nodes::Case.new 159 | end 160 | 161 | # Create assignments expression for UPDATE statement 162 | # 163 | # @example 164 | # Model.where(:parent_id => nil).update_all(set :name => switch.when(x < 10).then('OK').else('TOO LARGE')) 165 | # 166 | # @param [Hash] assignments 167 | def set(assignments) 168 | assignments.map do |attr, value| 169 | next unless attr.present? 170 | 171 | name = attr.is_a?(Arel::Attributes::Attribute) ? attr.name : attr.to_s 172 | 173 | quoted = record.class.connection.quote_column_name(name) 174 | "#{quoted} = (#{value.to_sql})" 175 | end.join(', ') 176 | end 177 | 178 | def attribute(name) 179 | name && Attribute.new(table, name.to_sym) 180 | end 181 | 182 | def expression(expr) 183 | SqlLiteral.new(expr.to_s) 184 | end 185 | 186 | def id 187 | attribute(record.ordered_tree.columns.id) 188 | end 189 | 190 | def parent_id 191 | attribute(record.ordered_tree.columns.parent) 192 | end 193 | 194 | def position 195 | attribute(record.ordered_tree.columns.position) 196 | end 197 | 198 | def depth 199 | attribute(record.ordered_tree.columns.depth) 200 | end 201 | 202 | def table 203 | record.class.arel_table 204 | end 205 | 206 | def method_missing(id, *args) 207 | if args.length > 0 208 | # function 209 | NamedFunction.new(id.to_s.upcase, args) 210 | else 211 | super 212 | end 213 | end 214 | end # module DSL 215 | end # module Transaction 216 | end # module ActsAsOrderedTree 217 | -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/transaction/factory.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/position' 4 | require 'acts_as_ordered_tree/transaction/create' 5 | require 'acts_as_ordered_tree/transaction/destroy' 6 | require 'acts_as_ordered_tree/transaction/move' 7 | require 'acts_as_ordered_tree/transaction/passthrough' 8 | require 'acts_as_ordered_tree/transaction/reorder' 9 | 10 | module ActsAsOrderedTree 11 | module Transaction 12 | # @api private 13 | module Factory 14 | # Creates previous and current position objects for node 15 | # @api private 16 | class PositionFactory 17 | def initialize(node) 18 | @node = node 19 | end 20 | 21 | def previous 22 | Position.new @node, @node.parent_id_was, @node.position_was 23 | end 24 | 25 | def current 26 | Position.new @node, @node.parent_id, @node.position 27 | end 28 | 29 | def transition 30 | Position::Transition.new(previous, current) 31 | end 32 | end 33 | private_constant :PositionFactory 34 | 35 | # Creates proper transaction according to +node+ 36 | # 37 | # @param [ActsAsOrderedTree::Node] node 38 | # @param [true, false] destroy set to true if node should be destroyed 39 | # @return [ActsAsOrderedTree::Transaction::Base] 40 | def create(node, destroy = false) 41 | pos = PositionFactory.new(node) 42 | 43 | case 44 | when destroy 45 | Destroy.new(node, pos.previous) 46 | when node.record.new_record? 47 | Create.new(node, pos.current) 48 | else 49 | create_from_transition(node, pos.transition) 50 | end 51 | end 52 | module_function :create 53 | 54 | def create_from_transition(node, transition) 55 | case 56 | when transition.movement? 57 | Move.new(node, transition) 58 | when transition.reorder? 59 | Reorder.new(node, transition) 60 | else 61 | Passthrough.new 62 | end 63 | end 64 | module_function :create_from_transition 65 | end 66 | end 67 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/transaction/move.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/transaction/update' 4 | 5 | module ActsAsOrderedTree 6 | module Transaction 7 | class Move < Update 8 | before 'trigger_callback(:before_remove, from.parent)' 9 | before 'trigger_callback(:before_add, to.parent)' 10 | 11 | after :update_descendants_depth, :if => [ 12 | 'transition.movement?', 13 | 'tree.columns.depth?', 14 | 'transition.level_changed?', 15 | 'record.children.size > 0' 16 | ] 17 | 18 | after 'trigger_callback(:after_add, to.parent)' 19 | after 'trigger_callback(:after_remove, from.parent)' 20 | after 'transition.update_counters' 21 | 22 | finalize 23 | 24 | private 25 | def update_values 26 | updates = Hash[ 27 | position => position_value, 28 | parent_id => parent_id_value 29 | ] 30 | 31 | updates[depth] = depth_value if tree.columns.depth? && transition.level_changed? 32 | 33 | updates 34 | end 35 | 36 | # Records to be updated 37 | def update_scope 38 | filter = (id == record.id) | (parent_id == from.parent_id) | (parent_id == to.parent_id) 39 | node.scope.where(filter.to_sql) 40 | end 41 | 42 | def parent_id_value 43 | switch.when(id == record.id, to.parent_id).else(parent_id) 44 | end 45 | 46 | def position_value 47 | switch. 48 | when(id == record.id). 49 | then(@to.position). 50 | # decrement lower positions in old parent 51 | when((parent_id == from.parent_id) & (position > from.position)). 52 | then(position - 1). 53 | # increment positions in new parent 54 | when((parent_id == to.parent_id) & (position >= to.position)). 55 | then(position + 1). 56 | else(position) 57 | end 58 | 59 | def depth_value 60 | switch. 61 | when(id == record.id, to.depth). 62 | else(depth) 63 | end 64 | 65 | def update_descendants_depth 66 | record.descendants.update_all set depth => depth + (to.depth - from.depth) 67 | end 68 | end 69 | end 70 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/transaction/passthrough.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | module ActsAsOrderedTree 4 | module Transaction 5 | # Null transaction, does nothing but delegates to caller 6 | class Passthrough 7 | def start(&block) 8 | block.call if block_given? 9 | end 10 | end 11 | end 12 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/transaction/reorder.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/transaction/update' 4 | 5 | module ActsAsOrderedTree 6 | module Transaction 7 | class Reorder < Update 8 | finalize 9 | 10 | protected 11 | # if we reorder node then we cannot put it to position higher than highest 12 | def push_to_bottom 13 | to.position = highest_position.zero? ? 1 : highest_position 14 | end 15 | 16 | private 17 | def update_scope 18 | to.siblings.where(positions_range) 19 | end 20 | 21 | def update_values 22 | { position => position_value } 23 | end 24 | 25 | def positions_range 26 | position.in([from.position, to.position].min..[from.position, to.position].max) 27 | end 28 | 29 | def position_value 30 | expr = switch. 31 | when(position == from.position).then(to.position). 32 | else(position) 33 | 34 | if to.position > from.position 35 | expr.when(positions_range).then(position - 1) 36 | else 37 | expr.when(positions_range).then(position + 1) 38 | end 39 | end 40 | end # class Reorder 41 | end # module Transaction 42 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/transaction/save.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/transaction/base' 4 | 5 | module ActsAsOrderedTree 6 | module Transaction 7 | class Save < Base 8 | attr_reader :to 9 | 10 | before 'to.lock!' 11 | before :set_scope!, :if => 'to.parent?' 12 | before :push_to_bottom, :if => :push_to_bottom? 13 | before 'to.position = 1', :if => 'to.position <= 0' 14 | 15 | around :copy_attributes 16 | 17 | # @param [ActsAsOrderedTree::Node] node 18 | # @param [ActsAsOrderedTree::Position] to to which position given +node+ is saved 19 | def initialize(node, to) 20 | super(node) 21 | @to = to 22 | end 23 | 24 | protected 25 | # Copies parent_id, position and depth from destination to record 26 | def copy_attributes 27 | record.parent = to.parent 28 | node.position = to.position 29 | node.depth = to.depth if tree.columns.depth? 30 | 31 | yield 32 | end 33 | 34 | # Returns highest position within node's siblings 35 | def highest_position 36 | @highest_position ||= to.siblings.maximum(tree.columns.position) || 0 37 | end 38 | 39 | # Should be fired when given position is empty 40 | def push_to_bottom 41 | to.position = highest_position + 1 42 | end 43 | 44 | # Returns true if record should be pushed to bottom of list 45 | def push_to_bottom? 46 | to.position.blank? || 47 | position_out_of_bounds? 48 | end 49 | 50 | private 51 | def set_scope! 52 | tree.columns.scope.each do |column| 53 | record[column] = to.parent[column] 54 | end 55 | 56 | nil 57 | end 58 | 59 | def position_out_of_bounds? 60 | to.position > highest_position 61 | end 62 | end # class Save 63 | end # module Transaction 64 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/transaction/update.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'active_support/core_ext/object/with_options' 4 | 5 | require 'acts_as_ordered_tree/position' 6 | require 'acts_as_ordered_tree/transaction/save' 7 | require 'acts_as_ordered_tree/transaction/dsl' 8 | 9 | module ActsAsOrderedTree 10 | module Transaction 11 | # Update transaction includes Move and Reorder 12 | # 13 | # @abstract 14 | # @api private 15 | class Update < Save 16 | include DSL 17 | 18 | attr_reader :from, :transition 19 | 20 | around :update_tree 21 | 22 | # @param [ActsAsOrderedTree::Node] node 23 | # @param [ActsAsOrderedTree::Position::Transition] transition 24 | def initialize(node, transition) 25 | @transition = transition 26 | @from = transition.from 27 | 28 | super(node, transition.to) 29 | end 30 | 31 | protected 32 | def update_tree 33 | callbacks = transition.reorder? ? :reorder : :move 34 | 35 | record.run_callbacks(callbacks) do 36 | record.hook_update do |update| 37 | update.scope = update_scope 38 | update.values = update_values.merge(changed_attributes) 39 | 40 | yield 41 | end 42 | end 43 | end 44 | 45 | def update_scope 46 | # implement in successors 47 | end 48 | 49 | def update_values 50 | # implement in successors 51 | end 52 | 53 | private 54 | # Returns hash of UPDATE..SET expressions for each 55 | # changed record attribute (except tree attributes) 56 | # 57 | # @return [Hash Arel::Nodes::Node>] 58 | def changed_attributes 59 | changed_attributes_names.each_with_object({}) do |attr, hash| 60 | hash[attr] = attribute_value(attr) 61 | end 62 | end 63 | 64 | def attribute_value(attr) 65 | attr_value = record.read_attribute(attr) 66 | quoted = record.class.connection.quote(attr_value) 67 | 68 | switch. 69 | when(id == record.id).then(Arel.sql(quoted)). 70 | else(attribute(attr)) 71 | end 72 | 73 | def changed_attributes_names 74 | record.changed - (tree.columns.to_a - tree.columns.scope) 75 | end 76 | end 77 | end 78 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/tree.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/compatibility' 4 | 5 | require 'acts_as_ordered_tree/tree/callbacks' 6 | require 'acts_as_ordered_tree/tree/columns' 7 | require 'acts_as_ordered_tree/tree/children_association' 8 | require 'acts_as_ordered_tree/tree/deprecated_columns_accessors' 9 | require 'acts_as_ordered_tree/tree/parent_association' 10 | require 'acts_as_ordered_tree/tree/perseverance' 11 | require 'acts_as_ordered_tree/tree/scopes' 12 | 13 | require 'acts_as_ordered_tree/hooks' 14 | 15 | require 'acts_as_ordered_tree/adapters' 16 | require 'acts_as_ordered_tree/validators' 17 | 18 | require 'acts_as_ordered_tree/instance_methods' 19 | 20 | module ActsAsOrderedTree 21 | # ActsAsOrderedTree::Tree 22 | class Tree 23 | # Default ordered tree options 24 | DEFAULT_OPTIONS = { 25 | :parent_column => :parent_id, 26 | :position_column => :position, 27 | :depth_column => :depth 28 | }.freeze 29 | 30 | PROTECTED_ATTRIBUTES = :left_sibling, :left_sibling_id, 31 | :higher_item, :higher_item_id, 32 | :right_sibling, :right_sibling_id, 33 | :lower_item, :lower_item_id 34 | 35 | attr_reader :klass 36 | 37 | # @!attribute [r] columns 38 | # Columns information aggregator 39 | # 40 | # @return [ActsAsOrderedTree::Tree::Columns] column object 41 | attr_reader :columns 42 | 43 | # @!attribute [r] callbacks 44 | # :before_add, :after_add, :before_remove and :after_remove callbacks storage 45 | # 46 | # @return [ActsAsOrderedTree::Tree::Callbacks] callbacks object 47 | attr_reader :callbacks 48 | 49 | # @!attribute [r] adapter 50 | # Ordered tree adapter which contains implementation of some traverse methods 51 | # 52 | # @return [ActsAsOrderedTree::Adapters::Abstract] adapter object 53 | attr_reader :adapter 54 | 55 | # @!attribute [r] options 56 | # Ordered tree options 57 | # 58 | # @return [Hash] 59 | attr_reader :options 60 | 61 | # Create and setup tree object 62 | # 63 | # @param [Class] klass 64 | # @param [Hash] options 65 | def self.setup!(klass, options) 66 | klass.ordered_tree = new(klass, options).setup 67 | end 68 | 69 | # @param [Class] klass 70 | # @param [Hash] options 71 | def initialize(klass, options) 72 | @klass = klass 73 | @options = DEFAULT_OPTIONS.merge(options).freeze 74 | @columns = Columns.new(klass, @options) 75 | @callbacks = Callbacks.new(klass, @options) 76 | @children = ChildrenAssociation.new(self) 77 | @parent = ParentAssociation.new(self) 78 | @adapter = Adapters.lookup(klass.connection.adapter_name).new(self) 79 | end 80 | 81 | # Builds associations, callbacks, validations etc. 82 | def setup 83 | setup_associations 84 | setup_once 85 | 86 | self 87 | end 88 | 89 | # Returns Class object which will be used for associations, 90 | # scopes and tree traversals. 91 | # 92 | # @return [Class] 93 | def base_class 94 | if klass.finder_needs_type_condition? 95 | klass.base_class 96 | else 97 | klass 98 | end 99 | end 100 | 101 | private 102 | def already_setup? 103 | klass.ordered_tree? 104 | end 105 | 106 | def setup_once 107 | return if already_setup? 108 | 109 | setup_validations 110 | setup_callbacks 111 | protect_attributes *PROTECTED_ATTRIBUTES 112 | 113 | klass.class_eval do 114 | extend Scopes 115 | extend DeprecatedColumnsAccessors 116 | 117 | include InstanceMethods 118 | include Perseverance 119 | include Hooks 120 | end 121 | end 122 | 123 | def setup_associations 124 | @parent.build 125 | @children.build 126 | end 127 | 128 | def setup_validations 129 | if columns.scope? 130 | klass.validates_with Validators::ScopeValidator, :on => :update, :if => :parent 131 | end 132 | 133 | klass.validates_with Validators::CyclicReferenceValidator, :on => :update, :if => :parent 134 | end 135 | 136 | def setup_callbacks 137 | klass.define_model_callbacks(:move, :reorder) 138 | klass.around_save(:save_ordered_tree_node) 139 | klass.around_destroy(:destroy_ordered_tree_node) 140 | end 141 | 142 | def protect_attributes(*attributes) 143 | Compatibility.version '< 4.0.0' do 144 | klass.attr_protected *attributes 145 | end 146 | end 147 | end 148 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/tree/association.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | module ActsAsOrderedTree 4 | class Tree 5 | class Association 6 | attr_reader :tree 7 | 8 | delegate :klass, :to => :tree 9 | 10 | def initialize(tree) 11 | @tree = tree 12 | end 13 | 14 | protected 15 | def class_name 16 | "::#{tree.base_class.name}" 17 | end 18 | end # class Association 19 | end # class Tree 20 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/tree/callbacks.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'active_support/core_ext/hash/slice' 4 | 5 | module ActsAsOrderedTree 6 | class Tree 7 | # Tree callbacks storage 8 | # 9 | # @example 10 | # MyModel.ordered_tree.callbacks.before_add(parent, child) 11 | # 12 | # @api private 13 | class Callbacks 14 | VALID_KEYS = :before_add, 15 | :after_add, 16 | :before_remove, 17 | :after_remove 18 | 19 | def initialize(klass, options) 20 | @klass = klass 21 | @callbacks = {} 22 | 23 | options.slice(*VALID_KEYS).each do |k, v| 24 | @callbacks[k] = v if v 25 | end 26 | end 27 | 28 | # generate accessors and predicates 29 | VALID_KEYS.each do |method| 30 | define_method(method) do |parent, record| # def before_add(parent, record) 31 | run_callbacks(method, parent, record) 32 | end 33 | end 34 | 35 | private 36 | def run_callbacks(method, parent, record) 37 | callback = callback_for(method) 38 | 39 | case callback 40 | when Symbol 41 | parent.send(callback, record) 42 | when Proc 43 | callback.call(parent, record) 44 | when nil, false 45 | # do nothing 46 | else 47 | # parent.before_add(record) 48 | callback.send(method, parent, record) 49 | end 50 | end 51 | 52 | def callback_for(method) 53 | @callbacks[method] 54 | end 55 | end # class Callbacks 56 | end # class Tree 57 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/tree/children_association.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/compatibility' 4 | require 'acts_as_ordered_tree/tree/association' 5 | require 'acts_as_ordered_tree/relation/iterable' 6 | 7 | module ActsAsOrderedTree 8 | class Tree 9 | # @api private 10 | class ChildrenAssociation < Association 11 | # CounterCache extensions will allow to use cached value 12 | # 13 | # @api private 14 | module CounterCache 15 | def size 16 | ordered_tree_node.parent_id_changed? ? super : ordered_tree_node.counter_cache 17 | end 18 | 19 | def empty? 20 | size == 0 21 | end 22 | 23 | private 24 | def ordered_tree_node 25 | @association.owner.ordered_tree_node 26 | end 27 | end 28 | 29 | # Builds association object 30 | def build 31 | Compatibility.version '< 4.0.0' do 32 | opts = options.merge(:conditions => conditions, :order => order) 33 | 34 | klass.has_many(:children, opts) 35 | end 36 | 37 | Compatibility.version '>= 4.0.0' do 38 | klass.has_many(:children, scope, options) 39 | end 40 | end 41 | 42 | private 43 | def options 44 | Hash[ 45 | :class_name => class_name, 46 | :foreign_key => tree.columns.parent, 47 | :inverse_of => inverse_of, 48 | :dependent => :destroy, 49 | :extend => [extension, Relation::Iterable].compact 50 | ] 51 | end 52 | 53 | def inverse_of 54 | :parent unless tree.options[:polymorphic] 55 | end 56 | 57 | # rails 4.x scope for has_many association 58 | def scope 59 | assoc_scope = method(:association_scope) 60 | join_scope = method(:join_association_scope) 61 | 62 | ->(join_or_parent) { 63 | if join_or_parent.is_a?(ActiveRecord::Associations::JoinDependency::JoinAssociation) 64 | join_scope[join_or_parent] 65 | elsif join_or_parent.is_a?(ActiveRecord::Base) 66 | assoc_scope[join_or_parent] 67 | else 68 | where(nil) 69 | end.extending(Relation::Iterable) 70 | } 71 | end 72 | 73 | # Rails 3.x :conditions options for has_many association 74 | def conditions 75 | return nil unless tree.columns.scope? 76 | 77 | assoc_scope = method(:association_scope) 78 | join_scope = method(:join_association_scope) 79 | 80 | Proc.new do |join_association| 81 | conditions = if join_association.is_a?(ActiveRecord::Associations::JoinDependency::JoinAssociation) 82 | join_scope[join_association] 83 | elsif self.is_a?(ActiveRecord::Base) 84 | assoc_scope[self] 85 | else 86 | where(nil) 87 | end.where_values.reduce(:and) 88 | 89 | conditions.try(:to_sql) 90 | end 91 | end 92 | 93 | def order 94 | tree.columns.position 95 | end 96 | 97 | def extension 98 | if tree.columns.counter_cache? 99 | CounterCache 100 | end 101 | end 102 | 103 | def join_association_scope(join_association) 104 | parent = join_association.respond_to?(:parent) ? 105 | join_association.parent.table : 106 | join_association.base_klass.arel_table 107 | 108 | child = join_association.table 109 | 110 | conditions = tree.columns.scope.map { |column| parent[column].eq child[column] }.reduce(:and) 111 | 112 | klass.unscoped.where(conditions) 113 | end 114 | 115 | def association_scope(owner) 116 | owner.ordered_tree_node.scope.order(tree.columns.position) 117 | end 118 | end # class ChildrenAssociation 119 | end # class Tree 120 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/tree/columns.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | module ActsAsOrderedTree 4 | class Tree 5 | # Ordered tree columns store 6 | # 7 | # @example 8 | # MyModel.tree.columns.parent # => "parent_id" 9 | # MyModel.tree.columns.counter_cache # => nil 10 | # MyModel.tree.columns.counter_cache? # => false 11 | class Columns 12 | # This error is raised when unknown column given in :scope option 13 | UnknownColumn = Class.new(StandardError) 14 | 15 | # @api private 16 | def self.column_accessor(*names) 17 | names.each do |name| 18 | define_method "#{name}=" do |value| 19 | @columns[name] = value.to_s if column_exists?(value) 20 | end 21 | private "#{name}=".to_sym 22 | 23 | define_method "#{name}?" do 24 | @columns[name].present? 25 | end 26 | 27 | define_method name do 28 | @columns[name] 29 | end 30 | end 31 | end 32 | 33 | # @!method parent 34 | # @!method parent? 35 | # @!method parent=(value) 36 | # @!method position 37 | # @!method position? 38 | # @!method position=(value) 39 | # @!method depth 40 | # @!method depth? 41 | # @!method depth=(value) 42 | # @!method counter_cache 43 | # @!method counter_cache? 44 | # @!method counter_cache=(value) 45 | # @!method scope 46 | # @!method scope? 47 | column_accessor :parent, 48 | :position, 49 | :depth, 50 | :counter_cache, 51 | :scope 52 | 53 | def initialize(klass, options = {}) 54 | @klass = klass 55 | @columns = { :id => id } 56 | 57 | self.parent = options[:parent_column] 58 | self.position = options[:position_column] 59 | self.depth = options[:depth_column] 60 | self.counter_cache = counter_cache_name(options[:counter_cache]) 61 | self.scope = options[:scope] 62 | end 63 | 64 | def [](name) 65 | @columns[name] 66 | end 67 | 68 | def id 69 | @klass.primary_key 70 | end 71 | 72 | # Returns array of columns names associated with ordered tree structure 73 | def to_a 74 | @columns.values.flatten.compact 75 | end 76 | 77 | private 78 | undef_method :scope= 79 | def scope=(value) 80 | columns = Array.wrap(value) 81 | 82 | unknown = columns.reject { |name| column_exists?(name) } 83 | 84 | raise UnknownColumn, "Unknown column#{'s' if unknown.size > 1} passed to :scope option: #{unknown.join(', ')}" if unknown.any? 85 | 86 | @columns[:scope] = columns.map(&:to_s) 87 | end 88 | 89 | def counter_cache_name(value) 90 | if value == true 91 | "#{@klass.name.demodulize.underscore.pluralize}_count" 92 | else 93 | value 94 | end 95 | end 96 | 97 | def column_exists?(name) 98 | name.present? && @klass.columns_hash.include?(name.to_s) 99 | end 100 | end # class Columns 101 | end # class Tree 102 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/tree/deprecated_columns_accessors.rb: -------------------------------------------------------------------------------- 1 | module ActsAsOrderedTree 2 | class Tree 3 | # @deprecated Use `ordered_tree.columns` object 4 | module DeprecatedColumnsAccessors 5 | class << self 6 | # @api private 7 | def deprecated_method(method, delegate) 8 | define_method(method) do 9 | ActiveSupport::Deprecation.warn("#{name}.#{method} is deprecated in favor of #{name}.ordered_tree.columns.#{delegate}", caller(1)) 10 | 11 | ordered_tree.columns.send(delegate) 12 | end 13 | end 14 | private :deprecated_method 15 | end 16 | 17 | deprecated_method :parent_column, :parent 18 | deprecated_method :position_column, :position 19 | deprecated_method :depth_column, :depth 20 | deprecated_method :children_counter_cache_column, :counter_cache 21 | deprecated_method :scope_column_name, :scope 22 | end 23 | end 24 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/tree/parent_association.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/tree/association' 4 | 5 | module ActsAsOrderedTree 6 | class Tree 7 | class ParentAssociation < Association 8 | # create parent association 9 | # 10 | # we cannot use native :counter_cache callbacks because they suck! :( 11 | # they act like this: 12 | # node.parent = new_parent # and here counters are updated, outside of transaction! 13 | def build 14 | klass.belongs_to(:parent, options) 15 | end 16 | 17 | private 18 | def options 19 | Hash[ 20 | :class_name => class_name, 21 | :foreign_key => tree.columns.parent, 22 | :inverse_of => inverse_of 23 | ] 24 | end 25 | 26 | def inverse_of 27 | :children unless tree.options[:polymorphic] 28 | end 29 | end # class ParentAssociation 30 | end # class Tree 31 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/tree/perseverance.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'acts_as_ordered_tree/persevering_transaction' 4 | 5 | module ActsAsOrderedTree 6 | class Tree 7 | # This module contains overridden :with_transaction_returning_status method 8 | # which wraps itself into PerseveringTransaction. 9 | # 10 | # This module is mixed in into Class after Class.acts_as_ordered_tree invocation. 11 | # 12 | # @api private 13 | module Perseverance 14 | def with_transaction_returning_status 15 | PerseveringTransaction.new(self.class.connection).start { super } 16 | end 17 | end 18 | end 19 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/tree/scopes.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | module ActsAsOrderedTree 4 | class Tree 5 | module Scopes 6 | # Returns nodes ordered by their position. 7 | # 8 | # @return [ActiveRecord::Relation] 9 | def preorder 10 | order arel_table[ordered_tree.columns.position].asc 11 | end 12 | 13 | # Returns all nodes that don't have parent. 14 | # 15 | # @return [ActiveRecord::Relation] 16 | def roots 17 | preorder.where arel_table[ordered_tree.columns.parent].eq nil 18 | end 19 | 20 | # Returns all nodes that do not have any children. May be quite inefficient. 21 | # 22 | # @return [ActiveRecord::Relation] 23 | def root 24 | roots.first 25 | end 26 | 27 | # Returns all nodes that do not have any children. May be quite inefficient. 28 | # 29 | # @return [ActiveRecord::Relation] 30 | def leaves 31 | if ordered_tree.columns.counter_cache? 32 | leaves_with_counter_cache 33 | else 34 | leaves_without_counter_cache 35 | end 36 | end 37 | 38 | private 39 | def leaves_without_counter_cache 40 | aliaz = Arel::Nodes::TableAlias.new(arel_table, 't') 41 | 42 | subquery = unscoped.select('1'). 43 | from(aliaz). 44 | where(aliaz[ordered_tree.columns.parent].eq(arel_table[primary_key])). 45 | limit(1). 46 | reorder(nil) 47 | 48 | where "NOT EXISTS (#{subquery.to_sql})" 49 | end 50 | 51 | def leaves_with_counter_cache 52 | where arel_table[ordered_tree.columns.counter_cache].eq 0 53 | end 54 | end # module Scopes 55 | end # class Tree 56 | end # module ActsAsOrderedTree -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/validators.rb: -------------------------------------------------------------------------------- 1 | module ActsAsOrderedTree 2 | module Validators #:nodoc:all: 3 | class CyclicReferenceValidator < ActiveModel::Validator 4 | def validate(record) 5 | record.errors.add(:parent, :invalid) if record.is_or_is_ancestor_of?(record.parent) 6 | end 7 | end 8 | 9 | class ScopeValidator < ActiveModel::Validator 10 | def validate(record) 11 | record.errors.add(:parent, :scope) unless record.ordered_tree_node.same_scope?(record.parent) 12 | end 13 | end 14 | end 15 | end -------------------------------------------------------------------------------- /lib/acts_as_ordered_tree/version.rb: -------------------------------------------------------------------------------- 1 | module ActsAsOrderedTree 2 | VERSION = '2.0.0.beta3' 3 | end 4 | -------------------------------------------------------------------------------- /spec/acts_as_ordered_tree_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../spec_helper', __FILE__) 2 | 3 | describe ActsAsOrderedTree, :transactional do 4 | example 'creation_with_altered_column_names' do 5 | expect{RenamedColumns.create!}.not_to raise_error 6 | end 7 | 8 | describe '#level' do 9 | context 'given a persistent root node' do 10 | subject { create :default } 11 | 12 | it { expect(subject.level).to eq 0 } 13 | end 14 | 15 | context 'given a new root record' do 16 | subject { build :default } 17 | 18 | it { expect(subject.level).to eq 0 } 19 | end 20 | 21 | context 'given a persistent node with parent' do 22 | let(:root) { create :default } 23 | subject { create :default, :parent => root } 24 | 25 | it { expect(subject.level).to eq 1 } 26 | end 27 | 28 | context 'given a new node with parent' do 29 | let(:root) { create :default } 30 | subject { build :default, :parent => root } 31 | 32 | it { expect(subject.level).to eq 1 } 33 | end 34 | 35 | context 'a model without depth column' do 36 | tree :factory => :scoped do 37 | root { 38 | child { 39 | grandchild 40 | } 41 | } 42 | end 43 | 44 | before { root.reload } 45 | before { child.reload } 46 | before { grandchild.reload } 47 | 48 | it { expect(root.level).to eq 0 } 49 | it { expect{root.level}.not_to query_database } 50 | 51 | it { expect(child.level).to eq 1 } 52 | it { expect{child.level}.to query_database.once } 53 | 54 | it { expect(grandchild.level).to eq 2 } 55 | it { expect{grandchild.level}.to query_database.at_least(:once) } 56 | 57 | context 'given a record with already loaded parent' do 58 | before { child.association(:parent).load_target } 59 | before { grandchild.parent.association(:parent).load_target } 60 | 61 | it { expect(child.level).to eq 1 } 62 | it { expect{child.level}.not_to query_database } 63 | 64 | it { expect(grandchild.level).to eq 2 } 65 | it { expect{grandchild.level}.not_to query_database } 66 | end 67 | end 68 | end 69 | 70 | describe 'move actions' do 71 | tree :factory => :default_with_counter_cache do 72 | root { 73 | child_1 74 | child_2 :name => 'child_2' 75 | child_3 { 76 | child_4 77 | } 78 | } 79 | end 80 | 81 | describe '#insert_at' do 82 | before { ActiveSupport::Deprecation.silence { child_3.insert_at(1) } } 83 | before { child_3.reload } 84 | 85 | specify { expect([child_3, child_1, child_2]).to be_sorted } 86 | end 87 | end 88 | 89 | describe 'scoped trees' do 90 | tree :factory => :scoped do 91 | root1 :scope_type => 't1' do 92 | child1 93 | orphan 94 | end 95 | root2 :scope_type => 't2' do 96 | child2 97 | end 98 | end 99 | 100 | before { Scoped.where(:id => orphan.id).update_all(:scope_type => 't0', :position => 1) } 101 | 102 | it 'should not stick positions together for different scopes' do 103 | expect(root1.position).to eq root2.position 104 | end 105 | it 'should automatically set scope for new records with parent' do 106 | expect(child1.ordered_tree_node).to be_same_scope root1 107 | end 108 | it 'should not include orphans' do 109 | expect(root1.children.reload).not_to include orphan 110 | expect(root1.descendants.reload).not_to include orphan 111 | end 112 | it 'should not allow to move records between scopes' do 113 | expect(child2.move_to_child_of(root1)).to be false 114 | expect(child2.errors_on(:parent).size).to be >= 1 115 | end 116 | it 'should not allow to change scope' do 117 | child2.parent = root1 118 | expect(child2.errors_on(:parent).size).to be >= 1 119 | end 120 | it 'should not allow to add scoped record to children collection' do 121 | root1.children << child2 122 | expect(root1.children.reload).not_to include child2 123 | end 124 | end 125 | 126 | describe "potential vulnerabilities" do 127 | describe "attempt to link parent to one of descendants" do 128 | let(:root) { create :default } 129 | let(:child) { create :default, :parent => root } 130 | let(:grandchild) { create :default, :parent => child } 131 | 132 | subject { root } 133 | 134 | context "given self as parent" do 135 | before { root.parent = root } 136 | 137 | it 'has at least 1 error_on' do 138 | expect(subject.error_on(:parent).size).to be >= 1 139 | end 140 | end 141 | 142 | context "given child as parent" do 143 | before { root.parent = child } 144 | 145 | it 'has at least 1 error_on' do 146 | expect(subject.error_on(:parent).size).to be >= 1 147 | end 148 | end 149 | 150 | context "given grandchild as parent" do 151 | before { root.parent = grandchild } 152 | 153 | it 'has at least 1 error_on' do 154 | expect(subject.error_on(:parent).size).to be >= 1 155 | end 156 | end 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /spec/adapters/postgresql_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | require 'acts_as_ordered_tree/adapters/postgresql' 6 | require 'adapters/shared' 7 | 8 | describe ActsAsOrderedTree::Adapters::PostgreSQL, :transactional, :if => ENV['DB'] == 'pg' do 9 | it { expect(Default.ordered_tree.adapter).to be_a described_class } 10 | 11 | it_behaves_like 'ActsAsOrderedTree adapter', ActsAsOrderedTree::Adapters::PostgreSQL, :default 12 | it_behaves_like 'ActsAsOrderedTree adapter', ActsAsOrderedTree::Adapters::PostgreSQL, :default_with_counter_cache 13 | it_behaves_like 'ActsAsOrderedTree adapter', ActsAsOrderedTree::Adapters::PostgreSQL, :scoped 14 | end -------------------------------------------------------------------------------- /spec/adapters/recursive_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | require 'acts_as_ordered_tree/adapters/recursive' 6 | require 'adapters/shared' 7 | 8 | describe ActsAsOrderedTree::Adapters::Recursive, :transactional do 9 | it_behaves_like 'ActsAsOrderedTree adapter', ActsAsOrderedTree::Adapters::Recursive, :default 10 | it_behaves_like 'ActsAsOrderedTree adapter', ActsAsOrderedTree::Adapters::Recursive, :default_with_counter_cache 11 | it_behaves_like 'ActsAsOrderedTree adapter', ActsAsOrderedTree::Adapters::Recursive, :scoped 12 | end -------------------------------------------------------------------------------- /spec/callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | describe ActsAsOrderedTree, 'before/after add/remove callbacks', :transactional do 6 | class Class1 < Default 7 | cattr_accessor :triggered_callbacks 8 | 9 | def self.triggered?(kind, *args) 10 | if args.any? 11 | triggered_callbacks.include?([kind, *args]) 12 | else 13 | triggered_callbacks.any? { |c| c.first == kind } 14 | end 15 | end 16 | 17 | acts_as_ordered_tree :before_add => :before_add, 18 | :after_add => :after_add, 19 | :before_remove => :before_remove, 20 | :after_remove => :after_remove 21 | 22 | def run_callback(kind, *args) 23 | self.class.triggered_callbacks ||= [] 24 | self.class.triggered_callbacks << [kind, self, *args] 25 | yield if block_given? 26 | end 27 | 28 | CALLBACKS = [:before, :after, :around].product([:add, :remove, :move, :reorder]).map { |a, b| "#{a}_#{b}".to_sym } 29 | 30 | CALLBACKS.each do |callback| 31 | define_method(callback) { |*args, &block| run_callback(callback, *args, &block) } 32 | send(callback, callback) if respond_to?(callback) 33 | end 34 | end 35 | 36 | matcher :trigger_callback do |*callbacks, &block| 37 | supports_block_expectations 38 | 39 | match_for_should do |proc| 40 | @with ||= nil 41 | Class1.triggered_callbacks = [] 42 | proc.call 43 | callbacks.all? { |callback| Class1.triggered?(callback, *@with) } 44 | end 45 | 46 | match_for_should_not do |proc| 47 | @with ||= nil 48 | Class1.triggered_callbacks = [] 49 | proc.call 50 | callbacks.none? { |callback| Class1.triggered?(callback, *@with) } 51 | end 52 | 53 | chain :with do |*args, &blk| 54 | @with = args 55 | @block = blk 56 | end 57 | 58 | description do 59 | description = "trigger callbacks #{callbacks.map(&:inspect).join(', ')}" 60 | description << " with arguments #{@with.inspect}" if @with 61 | description 62 | end 63 | 64 | failure_message_for_should do 65 | "expected that block would #{description}" 66 | end 67 | 68 | failure_message_for_should_not do 69 | desc = "expected that block would not #{description}, but following callbacks were triggered:" 70 | Class1.triggered_callbacks.each do |kind, *args| 71 | desc << "\n * #{kind.inspect} #{args.inspect}" 72 | end 73 | desc 74 | end 75 | end 76 | alias_method :trigger_callbacks, :trigger_callback 77 | 78 | # root 79 | # child 1 80 | # child 2 81 | # child 3 82 | # child 4 83 | # child 5 84 | let(:root) { Class1.create :name => 'root' } 85 | let!(:child1) { Class1.create :name => 'child1', :parent => root } 86 | let!(:child2) { Class1.create :name => 'child2', :parent => child1 } 87 | let!(:child3) { Class1.create :name => 'child3', :parent => child1 } 88 | let!(:child4) { Class1.create :name => 'child4', :parent => root } 89 | let!(:child5) { Class1.create :name => 'child5', :parent => child4 } 90 | 91 | it 'does not trigger any callbacks when tree attributes were not changed' do 92 | expect { 93 | child2.update_attributes :name => 'x' 94 | }.not_to trigger_callbacks(*Class1::CALLBACKS) 95 | end 96 | 97 | it 'does not trigger any callbacks except :before_remove and :after_remove when node is destroyed' do 98 | expect { 99 | child2.destroy 100 | }.not_to trigger_callbacks(*Class1::CALLBACKS - [:before_remove, :after_remove]) 101 | end 102 | 103 | describe '*_add callbacks' do 104 | let(:new_record) { Class1.new :name => 'child6' } 105 | 106 | it 'fires *_add callbacks when new children added to node' do 107 | expect { 108 | child1.children << new_record 109 | }.to trigger_callbacks(:before_add, :after_add).with(child1, new_record) 110 | end 111 | 112 | it 'fires *_add callbacks when node is moved from another parent' do 113 | expect { 114 | child2.update_attributes!(:parent => child4) 115 | }.to trigger_callbacks(:before_add, :after_add).with(child4, child2) 116 | end 117 | end 118 | 119 | describe '*_remove callbacks' do 120 | it 'fires *_remove callbacks when node is moved from another parent' do 121 | expect { 122 | child2.update_attributes!(:parent => child4) 123 | }.to trigger_callbacks(:before_remove, :after_remove).with(child1, child2) 124 | end 125 | 126 | it 'fires *_remove callbacks when node is destroyed' do 127 | expect { 128 | child2.destroy 129 | }.to trigger_callbacks(:before_remove, :after_remove).with(child1, child2) 130 | end 131 | end 132 | 133 | describe '*_move callbacks' do 134 | it 'fires *_move callbacks when node is moved to another parent' do 135 | expect { 136 | child2.update_attributes!(:parent => child4) 137 | }.to trigger_callbacks(:before_move, :around_move, :after_move).with(child2) 138 | end 139 | 140 | it 'does not trigger *_move callbacks when node is not moved to another parent' do 141 | expect { 142 | child2.move_lower 143 | }.not_to trigger_callbacks(:before_move, :around_move, :after_move) 144 | 145 | expect { 146 | root.move_to_root 147 | }.not_to trigger_callbacks(:before_move, :around_move, :after_move) 148 | end 149 | end 150 | 151 | describe '*_reorder callbacks' do 152 | it 'fires *_reorder callbacks when node position is changed but parent not' do 153 | expect { 154 | child2.position += 1 155 | child2.save 156 | }.to trigger_callbacks(:before_reorder, :around_reorder, :after_reorder).with(child2) 157 | end 158 | 159 | it 'does not fire *_reorder callbacks when node is moved to another parent' do 160 | expect { 161 | child2.move_to_root 162 | }.not_to trigger_callbacks(:before_reorder, :around_reorder, :after_reorder) 163 | end 164 | end 165 | 166 | describe 'Callbacks context' do 167 | specify 'new parent_id should be available in before_move' do 168 | expect(child2).to receive(:before_move) { expect(child2.parent_id).to eq child4.id } 169 | child2.update_attributes! :parent => child4 170 | end 171 | 172 | specify 'new position should be available in before_reorder' do 173 | expect(child2).to receive(:before_reorder) { expect(child2.position).to eq 2 } 174 | child2.move_lower 175 | end 176 | end 177 | end -------------------------------------------------------------------------------- /spec/counter_cache_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | describe ActsAsOrderedTree, ':counter_cache option', :transactional do 6 | describe 'Class without counter cache, #children.size' do 7 | tree :factory => :default do 8 | root { 9 | child_1 10 | child_2 11 | } 12 | end 13 | 14 | it { expect(root.children.size).to eq 2 } 15 | it { expect{root.children.size}.to query_database.once } 16 | end 17 | 18 | describe 'Class with counter cache, #children.size' do 19 | tree :factory => :default_with_counter_cache do 20 | root { 21 | child_1 22 | child_2 23 | } 24 | end 25 | 26 | before { root.reload } 27 | 28 | it { expect(root.children.size).to eq 2 } 29 | it { expect{root.children.size}.not_to query_database } 30 | end 31 | end -------------------------------------------------------------------------------- /spec/create_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | describe ActsAsOrderedTree, 'Create node', :transactional do 6 | shared_examples 'create ordered tree node' do |model = :default| 7 | let(:record) { build model } 8 | 9 | before { record.parent = parent } 10 | 11 | context 'when position is nil' do 12 | before { record.position = nil } 13 | 14 | it 'does not change node parent' do 15 | expect{record.save}.not_to change(record, :parent) 16 | end 17 | 18 | it 'puts record to position = 1 when there are no siblings' do 19 | expect{record.save}.to change(record, :position).from(nil).to(1) 20 | end 21 | 22 | it 'puts record to bottom position when there are some siblings' do 23 | create model, :parent => parent 24 | 25 | expect{record.save}.to change(record, :position).from(nil).to(2) 26 | end 27 | 28 | it 'calculates depth column' do 29 | if record.ordered_tree.columns.depth? 30 | expect{record.save}.to change(record, :depth).from(nil).to(parent ? parent.depth + 1 : 0) 31 | end 32 | end 33 | end 34 | 35 | context 'when position != nil' do 36 | before { record.position = 3 } 37 | 38 | it 'changes position to 1 if siblings is empty' do 39 | expect{record.save}.to change(record, :position).from(3).to(1) 40 | end 41 | 42 | it 'changes position to highest if there are too few siblings' do 43 | create model, :parent => parent 44 | 45 | expect{record.save}.to change(record, :position).from(3).to(2) 46 | end 47 | 48 | it 'increments position of lower siblings on insert' do 49 | first = create model, :parent => parent 50 | second = create model, :parent => parent 51 | third = create model, :parent => parent 52 | 53 | expect(first.reload.position).to eq 1 54 | expect(second.reload.position).to eq 2 55 | expect(third.reload.position).to eq 3 56 | 57 | expect{record.save and third.reload}.to change(third, :position).from(3).to(4) 58 | 59 | expect(first.reload.position).to eq 1 60 | expect(second.reload.position).to eq 2 61 | expect(record.reload.position).to eq 3 62 | end 63 | 64 | it 'calculates depth column' do 65 | if record.ordered_tree.columns.depth? 66 | expect{record.save}.to change(record, :depth).from(nil).to(parent ? parent.depth + 1 : 0) 67 | end 68 | end 69 | end 70 | end 71 | 72 | context 'when parent is nil' do 73 | include_examples 'create ordered tree node' do 74 | let(:parent) { nil } 75 | end 76 | end 77 | 78 | context 'when parent exists' do 79 | include_examples 'create ordered tree node' do 80 | let(:parent) { create :default } 81 | end 82 | end 83 | 84 | context 'when parent exists (scoped)' do 85 | include_examples 'create ordered tree node', :scoped do 86 | let(:type) { 'scope' } 87 | let(:parent) { create :scoped, :scope_type => type } 88 | 89 | it 'copies scope columns values from parent node' do 90 | expect{record.save}.to change(record, :scope_type).to(parent.scope_type) 91 | end 92 | end 93 | end 94 | 95 | describe 'when counter_cache exists' do 96 | include_examples 'create ordered tree node', :default_with_counter_cache do 97 | let(:parent) { create :default_with_counter_cache } 98 | 99 | it 'sets counter_cache to 0 for new record' do 100 | record.save 101 | 102 | expect(record.categories_count).to eq 0 103 | end 104 | 105 | it 'increments counter_cache of parent' do 106 | expect{record.save and parent.reload}.to change(parent, :categories_count).by(1) 107 | end 108 | end 109 | end 110 | end -------------------------------------------------------------------------------- /spec/destroy_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | describe ActsAsOrderedTree, 'Destroy node', :transactional do 6 | shared_examples 'destroy ordered tree node' do |model = :default, attrs = {}| 7 | tree :factory => model, :attributes => attrs do 8 | root { 9 | child_1 { 10 | grandchild_1 { 11 | grandchild_2 12 | } 13 | } 14 | child_2 15 | child_3 16 | } 17 | end 18 | 19 | def assert_destroyed(record) 20 | expect(record.class).not_to exist(record.id) 21 | end 22 | 23 | it 'destroys descendants' do 24 | child_1.destroy 25 | 26 | assert_destroyed(grandchild_1) 27 | assert_destroyed(grandchild_2) 28 | end 29 | 30 | it 'decrements lower siblings positions' do 31 | child_1.destroy 32 | 33 | [child_2, child_3].each(&:reload) 34 | 35 | expect(child_2.position).to eq 1 36 | expect(child_3.position).to eq 2 37 | end 38 | end 39 | 40 | context 'Default model' do 41 | include_examples 'destroy ordered tree node', :default 42 | end 43 | 44 | context 'Scoped model' do 45 | include_examples 'destroy ordered tree node', :scoped, :scope_type => 't' 46 | end 47 | 48 | context 'Model with counter cache' do 49 | include_examples 'destroy ordered tree node', :default_with_counter_cache 50 | 51 | before { root.reload } 52 | 53 | it 'decrements parent children counter' do 54 | expect{child_1.destroy and root.reload}.to change(root, :categories_count).from(3).to(2) 55 | end 56 | end 57 | end -------------------------------------------------------------------------------- /spec/inheritance_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | describe ActsAsOrderedTree, 'inheritance without STI', :transactional do 6 | class BaseCategory < ActiveRecord::Base 7 | self.table_name = 'categories' 8 | 9 | acts_as_ordered_tree 10 | end 11 | 12 | class ConcreteCategory < BaseCategory 13 | end 14 | 15 | class ConcreteCategoryWithScope < BaseCategory 16 | default_scope { where(arel_table[:name].matches('* %')) } 17 | end 18 | 19 | let!(:root) { BaseCategory.create(:name => '* root') } 20 | let!(:child_1) { BaseCategory.create(:name => 'child 1', :parent => root) } 21 | let!(:child_2) { BaseCategory.create(:name => 'child 2', :parent => child_1) } 22 | let!(:child_3) { BaseCategory.create(:name => 'child 3', :parent => child_1) } 23 | let!(:child_4) { BaseCategory.create(:name => '* child 4', :parent => root) } 24 | let!(:child_5) { BaseCategory.create(:name => '* child 5', :parent => child_4) } 25 | let!(:child_6) { BaseCategory.create(:name => 'child 6', :parent => child_4) } 26 | 27 | matcher :be_of do |klass| 28 | match do |relation| 29 | expect(relation.map(&:class).uniq).to eq [klass] 30 | end 31 | end 32 | 33 | shared_examples 'Inheritance test' do |klass| 34 | describe "#{klass.name}#children" do 35 | let(:root_node) { root.becomes(klass) } 36 | 37 | it { expect(root_node).to be_a klass } 38 | it { expect(root_node.children).to be_of klass } 39 | end 40 | 41 | describe "#{klass.name}#parent" do 42 | let(:node) { child_5.becomes(klass) } 43 | 44 | it { expect(node.parent).to be_an_instance_of klass } 45 | end 46 | 47 | describe "#{klass.name}#root" do 48 | let(:root_node) { root.becomes(klass) } 49 | let(:node) { child_5.becomes(klass) } 50 | 51 | it { expect(node.root).to eq root_node } 52 | it { expect(node.root).to be_an_instance_of klass } 53 | end 54 | 55 | describe "#{klass.name}#descendants" do 56 | let(:node) { child_4.becomes(klass) } 57 | 58 | it { expect(node.descendants).to be_of klass } 59 | end 60 | 61 | describe "#{klass.name}#ancestors" do 62 | let(:node) { child_5.becomes(klass) } 63 | 64 | it { expect(node.ancestors).to be_of klass } 65 | end 66 | 67 | describe "#{klass.name} tree validators" do 68 | it 'calls validators only once' do 69 | expect_any_instance_of(ActsAsOrderedTree::Validators::CyclicReferenceValidator).to receive(:validate).once 70 | 71 | child_1.becomes(klass).save 72 | end 73 | end 74 | end 75 | 76 | include_examples 'Inheritance test', BaseCategory 77 | include_examples 'Inheritance test', ConcreteCategory 78 | include_examples 'Inheritance test', ConcreteCategoryWithScope do 79 | let(:klass) { ConcreteCategoryWithScope } 80 | 81 | describe 'ConcreteCategoryWithScope#children' do 82 | let(:node) { klass.find(child_4.id) } 83 | 84 | it { expect(node).to be_a klass } 85 | it { expect(node.children).to be_of klass } 86 | 87 | it 'applies class default scope to #children' do 88 | expect(node.children.size).to eq 1 89 | end 90 | end 91 | 92 | describe 'ConcreteCategoryWithScope#parent' do 93 | let(:orphaned) { child_2.becomes(klass) } 94 | let(:out_of_scope_with_proper_parent) { child_1.becomes(klass) } 95 | 96 | it { expect(orphaned.parent).to be_nil } 97 | it { expect(out_of_scope_with_proper_parent.parent).to eq root.becomes(klass) } 98 | end 99 | 100 | describe 'ConcreteCategoryWithScope#descendants' do 101 | let(:root_node) { klass.find(root.id) } 102 | 103 | it { expect(root_node.descendants).to be_of klass } 104 | it { expect(root_node.descendants.map(&:id)).to eq [child_4.id, child_5.id] } 105 | end 106 | end 107 | end 108 | 109 | describe ActsAsOrderedTree, 'inheritance with STI', :transactional do 110 | class StiRoot < StiExample 111 | end 112 | 113 | class StiExample1 < StiExample 114 | end 115 | 116 | class StiExample2 < StiExample 117 | end 118 | 119 | # build tree 120 | let!(:root) { StiRoot.create(:name => 'root') } 121 | let!(:child_1) { StiExample1.create(:name => 'child 1', :parent => root) } 122 | let!(:child_2) { StiExample1.create(:name => 'child 2', :parent => child_1) } 123 | let!(:child_3) { StiExample1.create(:name => 'child 3', :parent => child_1) } 124 | let!(:child_4) { StiExample2.create(:name => 'child 4', :parent => root) } 125 | let!(:child_5) { StiExample2.create(:name => 'child 5', :parent => child_4) } 126 | let!(:child_6) { StiExample2.create(:name => 'child 6', :parent => child_4) } 127 | 128 | before { [root, child_1, child_2, child_3, child_4, child_5, child_6].each(&:reload) } 129 | 130 | describe '#children' do 131 | it { expect(root.children).to eq [child_1, child_4] } 132 | end 133 | 134 | describe '#parent' do 135 | it { expect(child_1.parent).to eq root } 136 | end 137 | 138 | describe '#descendants' do 139 | it { expect(root.descendants).to eq [child_1, child_2, child_3, child_4, child_5, child_6] } 140 | end 141 | 142 | describe '#ancestors' do 143 | it { expect(child_5.ancestors).to eq [root, child_4] } 144 | end 145 | 146 | describe '#root' do 147 | it { expect(child_5.root).to eq root } 148 | end 149 | 150 | describe '#left_sibling' do 151 | it { expect(child_4.left_sibling).to eq child_1 } 152 | end 153 | 154 | describe '#right_sibling' do 155 | it { expect(child_1.right_sibling).to eq child_4 } 156 | end 157 | 158 | describe 'predicates' do 159 | it { expect(root).to be_is_ancestor_of(child_1) } 160 | it { expect(root).to be_is_or_is_ancestor_of(child_1) } 161 | it { expect(child_1).to be_is_descendant_of(root) } 162 | it { expect(child_1).to be_is_or_is_descendant_of(root) } 163 | end 164 | 165 | describe 'node reload' do 166 | it { expect(child_1.ordered_tree_node.reload).to eq child_1 } 167 | end 168 | 169 | describe 'node moving' do 170 | before { child_4.move_to_child_of(child_1) } 171 | 172 | it { expect(child_4.parent).to eq child_1 } 173 | it { expect(child_1.children).to include child_4 } 174 | it { expect(child_1.descendants).to include child_4 } 175 | end 176 | end -------------------------------------------------------------------------------- /spec/move_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | describe ActsAsOrderedTree, 'Movement via save', :transactional do 6 | tree :factory => :default_with_counter_cache do 7 | root { 8 | child_1 { 9 | child_2 10 | child_3 11 | } 12 | child_4 { 13 | child_5 14 | } 15 | } 16 | end 17 | 18 | def move(node, new_parent, new_position) 19 | name = "category #{rand(100..1000)}" 20 | node.parent = new_parent 21 | node.position = new_position 22 | node.name = name 23 | 24 | node.save 25 | 26 | expect(node.reload.parent).to eq new_parent 27 | expect(node.position).to eq new_position 28 | expect(node.name).to eq name 29 | end 30 | 31 | context 'when child 2 moved under child 4 to position 1' do 32 | before { move child_2, child_4, 1 } 33 | 34 | expect_tree_to_match { 35 | root { 36 | child_1 :categories_count => 1 do 37 | child_3 :position => 1 38 | end 39 | child_4 :categories_count => 2 do 40 | child_2 :position => 1 41 | child_5 :position => 2 42 | end 43 | } 44 | } 45 | end 46 | 47 | context 'when child 2 moved under child 4 to position 2' do 48 | before { move child_2, child_4, 2 } 49 | 50 | expect_tree_to_match { 51 | root { 52 | child_1 :categories_count => 1 do 53 | child_3 :position => 1 54 | end 55 | child_4 :categories_count => 2 do 56 | child_5 :position => 1 57 | child_2 :position => 2 58 | end 59 | } 60 | } 61 | end 62 | 63 | context 'when level changed' do 64 | before { move child_1, child_4, 1 } 65 | 66 | expect_tree_to_match { 67 | root { 68 | child_4 :categories_count => 2 do 69 | child_1 :position => 1, :categories_count => 2, :depth => 2 do 70 | child_2 :position => 1, :depth => 3 71 | child_3 :position => 2, :depth => 3 72 | end 73 | child_5 :position => 2, :depth => 2 74 | end 75 | } 76 | } 77 | end 78 | 79 | context 'when node moved to root' do 80 | before { move child_1, nil, 1 } 81 | 82 | expect_tree_to_match { 83 | child_1 :position => 1, :depth => 0 do 84 | child_2 :position => 1, :depth => 1 85 | child_3 :position => 2, :depth => 1 86 | end 87 | root :position => 2, :depth => 0 do 88 | child_4 :depth => 1 do 89 | child_5 :depth => 2 90 | end 91 | end 92 | } 93 | end 94 | end -------------------------------------------------------------------------------- /spec/node/movements/move_higher_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | describe ActsAsOrderedTree::Node::Movements, '#move_higher', :transactional do 6 | shared_examples '#move_higher' do |factory, attrs = {}| 7 | describe "#move_higher #{factory}" do 8 | tree :factory => factory, :attributes => attrs do 9 | node_1 10 | node_2 11 | node_3 12 | end 13 | 14 | context 'trying to move highest node up' do 15 | before { node_1.move_higher } 16 | 17 | expect_tree_to_match { 18 | node_1 19 | node_2 20 | node_3 21 | } 22 | end 23 | 24 | context 'trying to move node with position > 1' do 25 | before { node_2.move_higher } 26 | 27 | expect_tree_to_match { 28 | node_2 29 | node_1 30 | node_3 31 | } 32 | end 33 | 34 | context 'when attribute, not related to tree changed' do 35 | before { @old_name = node_3.name } 36 | before { node_3.name = 'new name' } 37 | 38 | it { expect{node_3.move_higher}.to change(node_3, :name).to(@old_name) } 39 | end 40 | end 41 | end 42 | 43 | include_examples '#move_higher', :default 44 | include_examples '#move_higher', :default_with_counter_cache 45 | include_examples '#move_higher', :scoped, :scope_type => 's' 46 | end -------------------------------------------------------------------------------- /spec/node/movements/move_lower_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | describe ActsAsOrderedTree::Node::Movements, '#move_lower', :transactional do 6 | shared_examples '#move_lower' do |factory, attrs = {}| 7 | describe "#move_lower #{factory}" do 8 | tree :factory => factory, :attributes => attrs do 9 | node_1 10 | node_2 11 | node_3 12 | end 13 | 14 | context 'trying to lowest node down' do 15 | before { node_3.move_lower } 16 | 17 | expect_tree_to_match { 18 | node_1 19 | node_2 20 | node_3 21 | } 22 | end 23 | 24 | context 'trying to move node with not lowest position' do 25 | before { node_2.move_lower } 26 | 27 | expect_tree_to_match { 28 | node_1 29 | node_3 30 | node_2 31 | } 32 | end 33 | 34 | context 'when attribute, not related to tree changed' do 35 | before { @old_name = node_2.name } 36 | before { node_2.name = 'new name' } 37 | 38 | it { expect{node_2.move_lower}.to change(node_2, :name).to(@old_name) } 39 | end 40 | end 41 | end 42 | 43 | include_examples '#move_lower', :default 44 | include_examples '#move_lower', :default_with_counter_cache 45 | include_examples '#move_lower', :scoped, :scope_type => 's' 46 | end -------------------------------------------------------------------------------- /spec/node/movements/move_to_child_of_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | describe ActsAsOrderedTree::Node::Movements, '#move_to_child_of', :transactional do 6 | shared_examples '#move_to_child_of' do |factory| 7 | describe "#move_to_child_of #{factory}" do 8 | tree :factory => factory do 9 | root { 10 | child_1 11 | child_2 12 | child_3 { 13 | child_4 14 | } 15 | } 16 | end 17 | 18 | context 'when AR object given' do 19 | it 'moves node' do 20 | expect { 21 | child_3.move_to_child_of(child_1) 22 | }.to change(child_3, :parent).from(root).to(child_1) 23 | end 24 | 25 | it 'does not change attributes unrelated to tree' do 26 | old, child_4.name = child_4.name, 'new name' 27 | 28 | expect { 29 | child_4.move_to_child_of(root) 30 | }.to change(child_4, :name).to(old) 31 | end 32 | 33 | context 'moving to child of self' do 34 | it { expect(child_3.move_to_child_of(child_3)).to be false } 35 | 36 | it 'does not move node' do 37 | expect { 38 | child_3.move_to_child_of(child_3) 39 | }.not_to change(child_3, :reload) 40 | end 41 | 42 | it 'invalidates node' do 43 | expect { 44 | child_3.move_to_child_of(child_3) 45 | }.to change(child_3, :valid?).from(true).to(false) 46 | end 47 | end 48 | 49 | context 'moving to child of current parent' do 50 | it 'does not move node' do 51 | expect { 52 | child_2.move_to_child_of(root) 53 | }.not_to change(child_2, :reload) 54 | end 55 | end 56 | 57 | context 'moving to child of descendant' do 58 | it { expect(root.move_to_child_of(child_1)).to be false } 59 | 60 | it 'does node move node' do 61 | expect { 62 | root.move_to_child_of(child_1) 63 | }.not_to change(root, :reload) 64 | end 65 | 66 | it 'invalidates node' do 67 | expect { 68 | root.move_to_child_of(child_1) 69 | }.to change(root, :valid?).from(true).to(false) 70 | end 71 | end 72 | 73 | context 'moving node deeper' do 74 | before { child_3.move_to_child_of(child_2) } 75 | 76 | expect_tree_to_match { 77 | root { 78 | child_1 79 | child_2 { 80 | child_3 { 81 | child_4 82 | } 83 | } 84 | } 85 | } 86 | end 87 | 88 | context 'moving node upper' do 89 | before { child_4.move_to_child_of(root) } 90 | 91 | expect_tree_to_match { 92 | root { 93 | child_1 94 | child_2 95 | child_3 96 | child_4 97 | } 98 | } 99 | end 100 | end 101 | 102 | context 'when ID given' do 103 | it 'moves node' do 104 | expect { 105 | child_3.move_to_child_of(child_1.id) 106 | }.to change(child_3, :parent).from(root).to(child_1) 107 | end 108 | 109 | it 'does not move node if parent was not changed' do 110 | expect { 111 | child_2.move_to_child_of(root.id) 112 | }.not_to change(child_2, :reload) 113 | end 114 | 115 | context 'moving to non-existent ID' do 116 | before { allow(child_3).to receive(:valid?).and_return(false) } 117 | 118 | it { expect(child_3.move_to_child_of(-1)).to be false } 119 | 120 | it 'does not move node' do 121 | expect { 122 | child_3.move_to_child_of(-1) 123 | }.not_to change(child_3, :reload) 124 | end 125 | end 126 | end 127 | 128 | context 'when nil given' do 129 | before { child_2.move_to_child_of(nil) } 130 | 131 | expect_tree_to_match { 132 | root { 133 | child_1 134 | child_3 { 135 | child_4 136 | } 137 | } 138 | child_2 139 | } 140 | end 141 | end 142 | end 143 | 144 | include_examples '#move_to_child_of', :default 145 | include_examples '#move_to_child_of', :default_with_counter_cache 146 | include_examples '#move_to_child_of', :scoped 147 | end -------------------------------------------------------------------------------- /spec/node/movements/move_to_child_with_index_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | describe ActsAsOrderedTree::Node::Movements, '#move_to_child_with_index', :transactional do 6 | shared_examples '#move_to_child_with_index' do |factory| 7 | describe "#move_to_child_with_index #{factory}" do 8 | tree :factory => factory do 9 | root { 10 | node_1 11 | node_2 12 | node_3 { 13 | node_4 14 | } 15 | } 16 | end 17 | 18 | context 'moving node to same parent and same position' do 19 | before { node_2.move_to_child_with_index(root, 1) } 20 | 21 | expect_tree_to_match { 22 | root { 23 | node_1 24 | node_2 25 | node_3 { 26 | node_4 27 | } 28 | } 29 | } 30 | end 31 | 32 | context 'moving node to same parent with another position' do 33 | before { node_1.move_to_child_with_index(root, 1) } 34 | 35 | expect_tree_to_match { 36 | root { 37 | node_2 38 | node_1 39 | node_3 { 40 | node_4 41 | } 42 | } 43 | } 44 | end 45 | 46 | context 'moving node to same parent to lowest position' do 47 | before { node_1.move_to_child_with_index(root, -1) } 48 | 49 | expect_tree_to_match { 50 | root { 51 | node_2 52 | node_3 { 53 | node_4 54 | } 55 | node_1 56 | } 57 | } 58 | end 59 | 60 | context 'moving node to position with negative index' do 61 | before { node_4.move_to_child_with_index(root, -2) } 62 | 63 | expect_tree_to_match { 64 | root { 65 | node_1 66 | node_4 67 | node_2 68 | node_3 69 | } 70 | } 71 | end 72 | 73 | context 'moving node to root with index starting from end' do 74 | before { node_4.move_to_child_with_index(nil, -1) } 75 | 76 | expect_tree_to_match { 77 | node_4 78 | root { 79 | node_1 80 | node_2 81 | node_3 82 | } 83 | } 84 | end 85 | 86 | context 'moving to node to very position with large negative index' do 87 | before { node_4.move_to_child_with_index(root, -100) } 88 | 89 | expect_tree_to_match { 90 | root { 91 | node_4 92 | node_1 93 | node_2 94 | node_3 95 | } 96 | } 97 | end 98 | 99 | context 'moving to node to very large position' do 100 | before { node_4.move_to_child_with_index(root, 100) } 101 | 102 | expect_tree_to_match { 103 | root { 104 | node_1 105 | node_2 106 | node_3 107 | node_4 108 | } 109 | } 110 | end 111 | 112 | context 'when attribute, not related to tree, changed' do 113 | before { @old_name = node_2.name } 114 | before { node_2.name = 'new name' } 115 | 116 | it { expect{node_2.move_to_child_with_index(root, 1)}.to change(node_2, :name).to(@old_name) } 117 | end 118 | end 119 | end 120 | 121 | include_examples '#move_to_child_with_index', :default 122 | include_examples '#move_to_child_with_index', :default_with_counter_cache 123 | include_examples '#move_to_child_with_index', :scoped 124 | end -------------------------------------------------------------------------------- /spec/node/movements/move_to_child_with_position_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | describe ActsAsOrderedTree::Node::Movements, '#move_to_child_with_position', :transactional do 6 | shared_examples '#move_to_child_with_position' do |factory| 7 | describe "#move_to_child_with_position #{factory}" do 8 | tree :factory => factory do 9 | root { 10 | node_1 11 | node_2 12 | node_3 { 13 | node_4 14 | } 15 | } 16 | end 17 | 18 | context 'moving node to same parent and same position' do 19 | before { node_2.move_to_child_with_position(root, 2) } 20 | 21 | expect_tree_to_match { 22 | root { 23 | node_1 24 | node_2 25 | node_3 { 26 | node_4 27 | } 28 | } 29 | } 30 | end 31 | 32 | context 'moving node to same parent with another position' do 33 | before { node_1.move_to_child_with_position(root, 2) } 34 | 35 | expect_tree_to_match { 36 | root { 37 | node_2 38 | node_1 39 | node_3 { 40 | node_4 41 | } 42 | } 43 | } 44 | end 45 | 46 | context 'moving node to same parent to lowest position' do 47 | before { node_1.move_to_child_with_position(root, 3) } 48 | 49 | expect_tree_to_match { 50 | root { 51 | node_2 52 | node_3 { 53 | node_4 54 | } 55 | node_1 56 | } 57 | } 58 | end 59 | 60 | context 'moving to node to very large position' do 61 | before { node_4.move_to_child_with_position(root, 100) } 62 | 63 | expect_tree_to_match { 64 | root { 65 | node_1 66 | node_2 67 | node_3 68 | node_4 69 | } 70 | } 71 | end 72 | 73 | context 'when attribute, not related to tree, changed' do 74 | before { @old_name = node_2.name } 75 | before { node_2.name = 'new name' } 76 | 77 | it { expect{node_2.move_to_child_with_position(root, 2)}.to change(node_2, :name).to(@old_name) } 78 | end 79 | end 80 | end 81 | 82 | include_examples '#move_to_child_with_position', :default 83 | include_examples '#move_to_child_with_position', :default_with_counter_cache 84 | include_examples '#move_to_child_with_position', :scoped 85 | end -------------------------------------------------------------------------------- /spec/node/movements/move_to_left_of_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | describe ActsAsOrderedTree::Node::Movements, '#move_to_left_of', :transactional do 6 | shared_examples '#move_to_left_of' do |factory| 7 | describe "#move_to_left_of #{factory}" do 8 | tree :factory => factory do 9 | root { 10 | node_1 11 | node_2 12 | node_3 { 13 | node_4 14 | } 15 | } 16 | end 17 | 18 | context 'moving node to next position' do 19 | before { node_1.move_to_left_of(node_2) } 20 | 21 | expect_tree_to_match { 22 | root { 23 | node_1 24 | node_2 25 | node_3 { 26 | node_4 27 | } 28 | } 29 | } 30 | end 31 | 32 | context 'moving node to same parent higher' do 33 | before { node_3.move_to_left_of(node_1) } 34 | 35 | expect_tree_to_match { 36 | root { 37 | node_3 { 38 | node_4 39 | } 40 | node_1 41 | node_2 42 | } 43 | } 44 | end 45 | 46 | context 'moving node to same parent lower' do 47 | before { node_1.move_to_left_of(node_3) } 48 | 49 | expect_tree_to_match { 50 | root { 51 | node_2 52 | node_1 53 | node_3 { 54 | node_4 55 | } 56 | } 57 | } 58 | end 59 | 60 | context 'moving inner node to left of root' do 61 | before { node_3.move_to_left_of(root) } 62 | 63 | expect_tree_to_match { 64 | node_3 { 65 | node_4 66 | } 67 | root { 68 | node_1 69 | node_2 70 | } 71 | } 72 | end 73 | 74 | context 'moving inner node to left of another inner node (shallower)' do 75 | before { node_4.move_to_left_of(node_1) } 76 | 77 | expect_tree_to_match { 78 | root { 79 | node_4 80 | node_1 81 | node_2 82 | node_3 83 | } 84 | } 85 | end 86 | 87 | context 'moving inner node to left of another inner node (deeper)' do 88 | before { node_1.move_to_left_of(node_4) } 89 | 90 | expect_tree_to_match { 91 | root { 92 | node_2 93 | node_3 { 94 | node_1 95 | node_4 96 | } 97 | } 98 | } 99 | end 100 | 101 | context 'attempt to perform impossible movement' do 102 | it { expect{ root.move_to_left_of(node_1) }.not_to change(current_tree, :all) } 103 | it { expect{ node_3.move_to_left_of(node_4) }.not_to change(current_tree, :all) } 104 | it { expect{ node_1.move_to_left_of(node_1) }.not_to change(current_tree, :all) } 105 | it { expect{ node_3.move_to_left_of(node_3) }.not_to change(current_tree, :all) } 106 | end 107 | 108 | context 'when attribute, not related to tree, changed' do 109 | before { @old_name = node_2.name } 110 | before { node_2.name = 'new name' } 111 | 112 | it { expect{node_2.move_to_left_of(node_4)}.to change(node_2, :name).to(@old_name) } 113 | end 114 | end 115 | end 116 | 117 | include_examples '#move_to_left_of', :default 118 | include_examples '#move_to_left_of', :default_with_counter_cache 119 | include_examples '#move_to_left_of', :scoped, :scope_type => 's' 120 | end -------------------------------------------------------------------------------- /spec/node/movements/move_to_right_of_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | describe ActsAsOrderedTree::Node::Movements, '#move_to_right_of', :transactional do 6 | shared_examples '#move_to_right_of' do |factory| 7 | describe "#move_to_right_of #{factory}" do 8 | tree :factory => factory do 9 | root { 10 | node_1 11 | node_2 12 | node_3 { 13 | node_4 14 | } 15 | } 16 | end 17 | 18 | context 'moving node to same parent higher' do 19 | before { node_3.move_to_right_of(node_1) } 20 | 21 | expect_tree_to_match { 22 | root { 23 | node_1 24 | node_3 { 25 | node_4 26 | } 27 | node_2 28 | } 29 | } 30 | end 31 | 32 | context 'moving node to next position' do 33 | before { node_1.move_to_right_of(node_2) } 34 | 35 | expect_tree_to_match { 36 | root { 37 | node_2 38 | node_1 39 | node_3 { 40 | node_4 41 | } 42 | } 43 | } 44 | end 45 | 46 | context 'moving node to same parent lower' do 47 | before { node_1.move_to_right_of(node_3) } 48 | 49 | expect_tree_to_match { 50 | root { 51 | node_2 52 | node_3 { 53 | node_4 54 | } 55 | node_1 56 | } 57 | } 58 | end 59 | 60 | context 'moving inner node to right of root node' do 61 | before { node_3.move_to_right_of(root) } 62 | 63 | expect_tree_to_match { 64 | root { 65 | node_1 66 | node_2 67 | } 68 | node_3 { 69 | node_4 70 | } 71 | } 72 | end 73 | 74 | context 'moving inner node to right of another inner node (shallower)' do 75 | before { node_4.move_to_right_of(node_1) } 76 | 77 | expect_tree_to_match { 78 | root { 79 | node_1 80 | node_4 81 | node_2 82 | node_3 83 | } 84 | } 85 | end 86 | 87 | context 'moving inner node to right of another inner node (deeper)' do 88 | before { node_1.move_to_right_of(node_4) } 89 | 90 | expect_tree_to_match { 91 | root { 92 | node_2 93 | node_3 { 94 | node_4 95 | node_1 96 | } 97 | } 98 | } 99 | end 100 | 101 | context 'Attempt to perform impossible movement' do 102 | it { expect{ root.move_to_right_of(node_1) }.not_to change(current_tree, :all) } 103 | it { expect{ node_3.move_to_right_of(node_4) }.not_to change(current_tree, :all) } 104 | it { expect{ node_1.move_to_right_of(node_1) }.not_to change(current_tree, :all) } 105 | it { expect{ node_3.move_to_right_of(node_3) }.not_to change(current_tree, :all) } 106 | end 107 | 108 | context 'when attribute, not related to tree changed' do 109 | before { @old_name = node_2.name } 110 | before { node_2.name = 'new name' } 111 | 112 | it { expect{node_2.move_to_right_of(node_4)}.to change(node_2, :name).to(@old_name) } 113 | end 114 | end 115 | end 116 | 117 | include_examples '#move_to_right_of', :default 118 | include_examples '#move_to_right_of', :default_with_counter_cache 119 | include_examples '#move_to_right_of', :scoped, :scope_type => 's' 120 | end -------------------------------------------------------------------------------- /spec/node/movements/move_to_root_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | describe ActsAsOrderedTree::Node::Movements, '#move_to_root', :transactional do 6 | shared_examples '#move_to_root' do |factory, attrs = {}| 7 | describe "#move_to_root #{factory}" do 8 | tree :factory => factory, :attributes => attrs do 9 | root_1 { 10 | node_1 11 | node_2 12 | node_3 { 13 | node_4 14 | } 15 | } 16 | root_2 { 17 | node_5 18 | } 19 | end 20 | 21 | context 'moving root node to root' do 22 | before { root_2.move_to_root } 23 | 24 | expect_tree_to_match { 25 | root_1 { 26 | node_1 27 | node_2 28 | node_3 { 29 | node_4 30 | } 31 | } 32 | root_2 { 33 | node_5 34 | } 35 | } 36 | end 37 | 38 | context 'moving inner node to root' do 39 | before { node_3.move_to_root } 40 | 41 | expect_tree_to_match { 42 | root_1 { 43 | node_1 44 | node_2 45 | } 46 | root_2 { 47 | node_5 48 | } 49 | node_3 { 50 | node_4 51 | } 52 | } 53 | end 54 | 55 | context 'when attribute, not related to tree changed' do 56 | before { @old_name = node_2.name } 57 | before { node_2.name = 'new name' } 58 | 59 | it { expect{node_2.move_to_root}.to change(node_2, :name).to(@old_name) } 60 | end 61 | end 62 | end 63 | 64 | include_examples '#move_to_root', :default 65 | include_examples '#move_to_root', :default_with_counter_cache 66 | include_examples '#move_to_root', :scoped, :scope_type => 's' 67 | end -------------------------------------------------------------------------------- /spec/node/reloading_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | describe ActsAsOrderedTree::Node::Reloading, :transactional do 6 | shared_examples 'ActsAsOrderedTree::Node#reload' do |model, attrs = {}| 7 | describe model.to_s.camelize, '#reload' do 8 | let(:record) { create model, attrs } 9 | let(:node) { record.ordered_tree_node } 10 | 11 | # change all attributes 12 | before { node.parent_id = create(model, attrs).id } 13 | before { node.position = 3 } 14 | before { node.depth = 2 if record.ordered_tree.columns.depth? } 15 | before { record[record.ordered_tree.columns.counter_cache] = 5 if record.ordered_tree.columns.counter_cache? } 16 | before { record.name = 'another name' } 17 | 18 | it 'reloads attributes related to tree' do 19 | node.reload 20 | 21 | expect(node.parent_id).to eq nil 22 | expect(node.position).to eq 1 23 | 24 | if record.ordered_tree.columns.depth? 25 | expect(node.depth).to eq 0 26 | end 27 | 28 | if record.class.ordered_tree.columns.counter_cache? 29 | expect(record.children.size).to eq 0 30 | end 31 | end 32 | 33 | it 'does not reload another attributes' do 34 | expect{node.reload}.not_to change(record, :name) 35 | end 36 | end 37 | end 38 | 39 | include_examples 'ActsAsOrderedTree::Node#reload', :default 40 | include_examples 'ActsAsOrderedTree::Node#reload', :default_with_counter_cache 41 | include_examples 'ActsAsOrderedTree::Node#reload', :scoped 42 | end -------------------------------------------------------------------------------- /spec/node/siblings_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | describe ActsAsOrderedTree::Node::Siblings, :transactional do 6 | shared_examples 'siblings' do |model| 7 | # silence pending examples 8 | # 9 | # @todo fix all xits 10 | def self.xit(*) end 11 | 12 | let(:root) { create model } 13 | let(:items) { create_list model, 3, :parent => root } 14 | 15 | # first 16 | it { expect(items.first.self_and_siblings).to eq items } 17 | it { expect(items.first.siblings).to eq [items.second, items.last] } 18 | 19 | it { expect(items.first.left_sibling).to be nil } 20 | it { expect(items.first.right_sibling).to eq items.second } 21 | 22 | it { expect(items.first.left_siblings).to be_empty } 23 | it { expect(items.first.right_siblings).to eq [items.second, items.last] } 24 | 25 | # second 26 | it { expect(items.second.self_and_siblings).to eq items } 27 | it { expect(items.second.siblings).to eq [items.first, items.last] } 28 | 29 | it { expect(items.second.left_sibling).to eq items.first } 30 | it { expect(items.second.right_sibling).to eq items.last } 31 | 32 | it { expect(items.second.left_siblings).to eq [items.first] } 33 | it { expect(items.second.right_siblings).to eq [items.last] } 34 | 35 | # last 36 | it { expect(items.last.self_and_siblings).to eq items } 37 | it { expect(items.last.siblings).to eq [items.first, items.second] } 38 | 39 | it { expect(items.last.left_sibling).to eq items.second } 40 | it { expect(items.last.right_sibling).to be nil } 41 | 42 | it { expect(items.last.left_siblings).to eq [items.first, items.second] } 43 | it { expect(items.last.right_siblings).to be_empty } 44 | 45 | context 'trying to set left or right sibling with random object' do 46 | def self.expect_type_mismatch_on(value) 47 | it "throws error when #{value.class} given" do 48 | expect { 49 | items.first.left_sibling = value 50 | }.to raise_error ActiveRecord::AssociationTypeMismatch 51 | 52 | expect { 53 | items.first.right_sibling = value 54 | }.to raise_error ActiveRecord::AssociationTypeMismatch 55 | end 56 | end 57 | 58 | def self.generate_class 59 | Class.new(ActiveRecord::Base){ self.table_name = 'categories' } 60 | end 61 | 62 | expect_type_mismatch_on(generate_class.new) 63 | expect_type_mismatch_on(nil) 64 | end 65 | 66 | context 'when left sibling is set' do 67 | context 'and new left sibling has same parent' do 68 | context 'and new left sibling is higher' do 69 | let(:item) { items.last } 70 | before { item.left_sibling = items.first } 71 | 72 | it { expect(item.parent).to eq items.first.parent } 73 | it { expect(item.position).to eq 2 } 74 | 75 | xit { expect(item.left_sibling).to eq items.first } 76 | xit { expect(item.right_siblings).to eq [items.second] } 77 | end 78 | 79 | context 'and new left sibling is lower' do 80 | let(:item) { items.first } 81 | before { item.left_sibling = items.last } 82 | 83 | it { expect(item.parent).to eq items.first.parent } 84 | it { expect(item.position).to eq 3 } 85 | 86 | xit { expect(item.left_sibling).to eq items.last } 87 | xit { expect(item.left_siblings).to eq [items.second, items.last] } 88 | end 89 | end 90 | 91 | context 'and new left sibling has other parent' do 92 | let(:item) { items.first } 93 | before { item.left_sibling = root } 94 | 95 | it { expect(item.parent).to be nil } 96 | it { expect(item.position).to eq 2 } 97 | 98 | xit { expect(item.left_sibling).to eq root } 99 | end 100 | 101 | context 'via #left_sibling_id=' do 102 | let(:item) { items.first } 103 | 104 | it 'throws error when non-existent ID given' do 105 | expect { 106 | item.left_sibling_id = -1 107 | }.to raise_error ActiveRecord::RecordNotFound 108 | end 109 | 110 | it 'delegates to #left_sibling=' do 111 | new_sibling = items.last 112 | 113 | expect(item.ordered_tree_node).to receive(:left_sibling=).with(new_sibling) 114 | item.left_sibling_id = new_sibling.id 115 | end 116 | end 117 | end 118 | 119 | context 'when right sibling is set' do 120 | context 'and new right sibling has same parent' do 121 | context 'and new right sibling is higher' do 122 | let(:item) { items.last } 123 | before { item.right_sibling = items.first } 124 | 125 | it { expect(item.parent).to eq items.first.parent } 126 | it { expect(item.position).to eq 1 } 127 | 128 | xit { expect(item.right_sibling).to eq item.first } 129 | xit { expect(item.left_siblings).to be_empty } 130 | xit { expect(item.right_siblings).to eq [items.first, items.second] } 131 | end 132 | 133 | context 'and new right sibling is lower' do 134 | let(:item) { items.first } 135 | before { item.right_sibling = items.last } 136 | 137 | it { expect(item.parent).to eq items.first.parent } 138 | it { expect(item.position).to eq 2 } 139 | 140 | xit { expect(item.right_sibling).to eq items.last } 141 | xit { expect(item.right_siblings).to eq [items.last] } 142 | 143 | xit { expect(item.left_sibling).to eq items.first } 144 | xit { expect(item.left_siblings).to eq [items.first] } 145 | end 146 | end 147 | 148 | context 'and new right sibling has other parent' do 149 | let(:item) { items.first } 150 | before { item.right_sibling = root } 151 | 152 | it { expect(item.parent).to be nil } 153 | it { expect(item.position).to eq 1 } 154 | 155 | xit { expect(item.right_sibling).to eq root } 156 | end 157 | 158 | context 'via #right_sibling_id=' do 159 | let(:item) { items.first } 160 | 161 | it 'throws error when non-existent ID given' do 162 | expect { 163 | item.right_sibling_id = -1 164 | }.to raise_error ActiveRecord::RecordNotFound 165 | end 166 | 167 | it 'delegates to #right_sibling=' do 168 | new_sibling = items.last 169 | 170 | expect(item.ordered_tree_node).to receive(:right_sibling=).with(new_sibling) 171 | item.right_sibling_id = new_sibling.id 172 | end 173 | end 174 | end 175 | end 176 | 177 | context 'Tree without scopes' do 178 | include_examples 'siblings', :default 179 | include_examples 'siblings', :default_with_counter_cache 180 | end 181 | 182 | context 'Tree with scope' do 183 | let!(:items_1) { create_list :scoped, 3, :scope_type => 's1' } 184 | let!(:items_2) { create_list :scoped, 3, :scope_type => 's2' } 185 | 186 | include_examples 'siblings', :scoped do 187 | let(:items) { items_1 } 188 | end 189 | include_examples 'siblings', :scoped do 190 | let(:items) { items_2 } 191 | end 192 | end 193 | end -------------------------------------------------------------------------------- /spec/node/traversals_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | describe ActsAsOrderedTree::Node::Traversals, :transactional do 6 | shared_examples 'ActsAsOrderedTree::Node traversals' do |model, attrs = {}| 7 | let(:root_1) { create model, attrs } 8 | let(:child_1) { create model, attrs.merge(:parent => root_1) } 9 | let(:grandchild_1) { create model, attrs.merge(:parent => child_1) } 10 | let(:root_2) { create model, attrs } 11 | let(:child_2) { create model, attrs.merge(:parent => root_2) } 12 | let(:grandchild_2) { create model, attrs.merge(:parent => child_2) } 13 | 14 | before { [root_1, child_1, grandchild_1].each(&:reload) } 15 | before { [root_2, child_2, grandchild_2].each(&:reload) } 16 | 17 | describe '#root' do 18 | it { expect(root_1.root).to eq root_1 } 19 | it { expect{root_1.root}.not_to query_database } 20 | it { expect(child_1.root).to eq root_1 } 21 | it { expect(grandchild_1.root).to eq root_1 } 22 | 23 | it { expect(root_2.root).to eq root_2 } 24 | it { expect{root_2.root}.not_to query_database } 25 | it { expect(child_2.root).to eq root_2 } 26 | it { expect(grandchild_2.root).to eq root_2 } 27 | end 28 | 29 | describe '#self_and_ancestors' do 30 | it { expect(root_1.self_and_ancestors).to eq [root_1] } 31 | it { expect{root_1.self_and_ancestors}.not_to query_database } 32 | it { expect(child_1.self_and_ancestors).to eq [root_1, child_1] } 33 | it { expect(grandchild_1.self_and_ancestors).to eq [root_1, child_1, grandchild_1] } 34 | 35 | it { expect(child_1.self_and_ancestors).to respond_to :each_with_level } 36 | it { expect(child_1.self_and_ancestors).to respond_to :each_without_orphans } 37 | end 38 | 39 | describe '#ancestors' do 40 | it { expect(root_1.ancestors).to eq [] } 41 | it { expect{root_1.ancestors}.not_to query_database } 42 | it { expect(child_1.ancestors).to eq [root_1] } 43 | it { expect(grandchild_1.ancestors).to eq [root_1, child_1] } 44 | 45 | it { expect(child_1.ancestors).to respond_to :each_with_level } 46 | it { expect(child_1.ancestors).to respond_to :each_without_orphans } 47 | end 48 | 49 | describe '#self_and_descendants' do 50 | it { expect(root_1.self_and_descendants).to eq [root_1, child_1, grandchild_1] } 51 | it { expect(child_1.self_and_descendants).to eq [child_1, grandchild_1] } 52 | it { expect(grandchild_1.self_and_descendants).to eq [grandchild_1] } 53 | 54 | it { expect(root_1.self_and_descendants).to respond_to :each_with_level } 55 | it { expect(root_1.self_and_descendants).to respond_to :each_without_orphans } 56 | end 57 | 58 | describe '#descendants' do 59 | it { expect(root_1.descendants).to eq [child_1, grandchild_1] } 60 | it { expect(child_1.descendants).to eq [grandchild_1] } 61 | it { expect(grandchild_1.descendants).to eq [] } 62 | 63 | it { expect(root_1.descendants).to respond_to :each_with_level } 64 | it { expect(root_1.descendants).to respond_to :each_without_orphans } 65 | end 66 | end 67 | 68 | include_examples 'ActsAsOrderedTree::Node traversals', :default 69 | include_examples 'ActsAsOrderedTree::Node traversals', :default_with_counter_cache 70 | include_examples 'ActsAsOrderedTree::Node traversals', :scoped, :scope_type => 'a' 71 | end -------------------------------------------------------------------------------- /spec/persevering_transaction_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | require 'acts_as_ordered_tree/persevering_transaction' 6 | 7 | describe ActsAsOrderedTree::PerseveringTransaction, :non_transactional do 8 | def create_transaction(connection = ActiveRecord::Base.connection) 9 | described_class.new(connection) 10 | end 11 | 12 | describe 'Transaction state' do 13 | def transaction 14 | @transaction ||= create_transaction 15 | end 16 | 17 | it 'becomes committed only when real transaction ends' do 18 | transaction.start do 19 | nested_transaction = create_transaction 20 | 21 | nested_transaction.start { } 22 | 23 | expect(nested_transaction).not_to be_committed 24 | expect(transaction).not_to be_committed 25 | end 26 | 27 | expect(transaction).to be_committed 28 | end 29 | 30 | it 'becomes rolledback when real transaction is rolledback' do 31 | transaction.start do 32 | raise ActiveRecord::Rollback 33 | end 34 | 35 | expect(transaction).to be_rolledback 36 | end 37 | end 38 | 39 | describe 'After commit callbacks' do 40 | it 'executes callbacks only when real transaction commits' do 41 | executed = [] 42 | 43 | outer = create_transaction 44 | outer.after_commit { executed << 1 } 45 | 46 | outer.start do 47 | inner = create_transaction 48 | inner.after_commit { executed << 2 } 49 | 50 | inner.start { } 51 | 52 | expect(executed).to be_empty 53 | end 54 | 55 | expect(executed).to eq [1, 2] 56 | end 57 | end 58 | 59 | describe 'Deadlock handling' do 60 | def start_in_thread(&block) 61 | Thread.start do 62 | ActiveRecord::Base.connection_pool.with_connection do |connection| 63 | trans = create_transaction(connection) 64 | trans.start(&block) 65 | trans 66 | end 67 | end 68 | end 69 | 70 | let!(:resource1) { Default.create!(:name => 'resource 1') } 71 | let!(:resource2) { Default.create!(:name => 'resource 2') } 72 | 73 | # this test randomly fails on Rails 3.1 74 | it 'Restarts transaction when deadlock occurred' do 75 | threads = [] 76 | 77 | threads << start_in_thread do 78 | resource1.lock! 79 | sleep 0.1 80 | resource2.lock! 81 | sleep 0.1 82 | end 83 | 84 | threads << start_in_thread do 85 | resource2.lock! 86 | sleep 0.1 87 | resource1.lock! 88 | sleep 0.1 89 | end 90 | 91 | transactions = threads.map(&:value) 92 | 93 | expect(transactions[0]).to be_committed 94 | expect(transactions[1]).to be_committed 95 | end 96 | end 97 | 98 | end -------------------------------------------------------------------------------- /spec/relation/arrangeable_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | describe ActsAsOrderedTree::Relation::Arrangeable, :transactional do 6 | tree :factory => :default do 7 | root { 8 | child_1 { 9 | grandchild_11 10 | grandchild_12 11 | } 12 | child_2 { 13 | grandchild_21 14 | grandchild_22 15 | } 16 | } 17 | end 18 | 19 | specify '#descendants scope should be arrangeable' do 20 | expect(root.descendants.arrange).to eq Hash[ 21 | child_1 => { 22 | grandchild_11 => {}, 23 | grandchild_12 => {} 24 | }, 25 | child_2 => { 26 | grandchild_21 => {}, 27 | grandchild_22 => {} 28 | } 29 | ] 30 | end 31 | 32 | specify '#self_and_descendants should be arrangeable' do 33 | expect(root.self_and_descendants.arrange).to eq Hash[ 34 | root => { 35 | child_1 => { 36 | grandchild_11 => {}, 37 | grandchild_12 => {} 38 | }, 39 | child_2 => { 40 | grandchild_21 => {}, 41 | grandchild_22 => {} 42 | } 43 | } 44 | ] 45 | end 46 | 47 | specify '#ancestors should be arrangeable' do 48 | expect(grandchild_11.ancestors.arrange).to eq Hash[ 49 | root => { 50 | child_1 => {} 51 | } 52 | ] 53 | end 54 | 55 | specify '#self_and_ancestors should be arrangeable' do 56 | expect(grandchild_11.self_and_ancestors.arrange).to eq Hash[ 57 | root => { 58 | child_1 => { 59 | grandchild_11 => {} 60 | } 61 | } 62 | ] 63 | end 64 | 65 | it 'should not discard orphaned nodes by default' do 66 | relation = root.descendants.where(root.class.arel_table[:id].not_eq(child_1.id)) 67 | 68 | expect(relation.arrange).to eq Hash[ 69 | grandchild_11 => {}, 70 | grandchild_12 => {}, 71 | child_2 => { 72 | grandchild_21 => {}, 73 | grandchild_22 => {} 74 | } 75 | ] 76 | end 77 | 78 | it 'should discard orphans if option :discard passed' do 79 | relation = root.descendants.where(root.class.arel_table[:id].not_eq(child_1.id)) 80 | 81 | expect(relation.arrange(:orphans => :discard)).to eq Hash[ 82 | child_2 => { 83 | grandchild_21 => {}, 84 | grandchild_22 => {} 85 | } 86 | ] 87 | end 88 | end -------------------------------------------------------------------------------- /spec/relation/iterable_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | require 'acts_as_ordered_tree/relation/iterable' 6 | 7 | describe ActsAsOrderedTree::Relation::Iterable, :transactional do 8 | shared_examples 'iterable' do |model| 9 | tree :factory => model do 10 | root_1 { 11 | child_1 { 12 | child_2 13 | } 14 | child_3 { 15 | child_4 16 | child_5 17 | } 18 | } 19 | root_2 { 20 | child_6 21 | } 22 | end 23 | 24 | describe '#each_with_level' do 25 | it 'iterates over collection and yields level' do 26 | relation = current_tree.order(:id).extending(described_class) 27 | 28 | expect { |b| 29 | relation.each_with_level(&b) 30 | }.to yield_successive_args [root_1, 0], 31 | [child_1, 1], 32 | [child_2, 2], 33 | [child_3, 1], 34 | [child_4, 2], 35 | [child_5, 2], 36 | [root_2, 0], 37 | [child_6, 1] 38 | end 39 | 40 | it 'computes level relative to first selected node' do 41 | expect { |b| 42 | root_1.descendants.extending(described_class).each_with_level(&b) 43 | }.to yield_successive_args [child_1, 1], 44 | [child_2, 2], 45 | [child_3, 1], 46 | [child_4, 2], 47 | [child_5, 2] 48 | end 49 | end 50 | 51 | describe '#each_without_orphans' do 52 | let(:relation) { current_tree.order(:id).extending(described_class) } 53 | 54 | it 'iterates over collection' do 55 | expect { |b| 56 | relation.each_without_orphans(&b) 57 | }.to yield_successive_args root_1, 58 | child_1, 59 | child_2, 60 | child_3, 61 | child_4, 62 | child_5, 63 | root_2, 64 | child_6 65 | end 66 | 67 | it 'iterates over collection and discards orphans' do 68 | expect { |b| 69 | relation.where('id != ?', child_3.id).each_without_orphans(&b) 70 | }.to yield_successive_args root_1, 71 | child_1, 72 | child_2, 73 | root_2, 74 | child_6 75 | end 76 | 77 | it 'iterates over collection and discards orphans' do 78 | expect { |b| 79 | relation.where('id != ?', root_2.id).each_without_orphans(&b) 80 | }.to yield_successive_args root_1, 81 | child_1, 82 | child_2, 83 | child_3, 84 | child_4, 85 | child_5 86 | end 87 | 88 | it 'iterates over collection and discards orphans' do 89 | expect { |b| 90 | relation.where('id != ?', root_1.id).each_without_orphans(&b) 91 | }.to yield_successive_args root_2, 92 | child_6 93 | end 94 | end 95 | end 96 | 97 | describe 'Model with cached level' do 98 | it_behaves_like 'iterable', :default 99 | end 100 | 101 | describe 'Model without cached level' do 102 | it_behaves_like 'iterable', :default_without_depth 103 | end 104 | end -------------------------------------------------------------------------------- /spec/relation/preloaded_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | require 'acts_as_ordered_tree/relation/preloaded' 6 | 7 | describe ActsAsOrderedTree::Relation::Preloaded, :transactional do 8 | let!(:records) { create_list :default, 2 } 9 | 10 | def relation 11 | Default.where(nil).extending(described_class) 12 | end 13 | 14 | context 'when preloaded records were not set' do 15 | it { expect(relation).to match_array records } 16 | it { expect(relation.to_a).not_to be records } 17 | it { expect{relation.to_a}.to query_database.once } 18 | end 19 | 20 | context 'when preloaded records were set directly' do 21 | let(:preloaded) { relation.records(records) } 22 | 23 | it { expect(preloaded).to eq records } 24 | 25 | it { expect(preloaded.to_a).to be records } 26 | it { expect{preloaded.to_a}.not_to query_database } 27 | 28 | it { expect(preloaded.size).to eq 2 } 29 | it { expect{preloaded.size}.not_to query_database } 30 | 31 | context 'when preloaded relation was extended' do 32 | let(:extended) { preloaded.extending(Module.new) } 33 | 34 | it { expect(extended).to eq records } 35 | 36 | it { expect(extended.to_a).to be records } 37 | it { expect{extended.to_a}.not_to query_database } 38 | 39 | it { expect(extended.size).to eq 2 } 40 | it { expect{extended.size}.not_to query_database } 41 | end 42 | 43 | describe '#reverse_order' do 44 | it { expect(preloaded.reverse_order).not_to be preloaded } 45 | it { expect(preloaded.reverse_order.size).to eq 2 } 46 | it { expect(preloaded.reverse_order).to eq records.reverse } 47 | it { expect{preloaded.reverse_order.to_a}.not_to query_database } 48 | end 49 | 50 | describe '#reverse_order!' do 51 | it { expect(preloaded.reverse_order!).to be preloaded } 52 | it { expect(preloaded.reverse_order!.size).to eq 2 } 53 | it { expect(preloaded.reverse_order!.to_a).to eq records.reverse } 54 | it { expect{preloaded.reverse_order!.to_a}.not_to query_database } 55 | end 56 | end 57 | end -------------------------------------------------------------------------------- /spec/reorder_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | describe ActsAsOrderedTree, 'Reorder via save', :transactional do 6 | tree :factory => :default do 7 | root { 8 | child_1 9 | child_2 10 | child_3 11 | } 12 | end 13 | 14 | def reorder(node, position) 15 | name = "category #{rand(100..1000)}" 16 | node.position = position 17 | node.name = name 18 | expect { node.save! }.not_to raise_error 19 | expect(node.name).to eq name 20 | end 21 | 22 | def assert_order(*nodes) 23 | nodes.each_with_index do |node, index| 24 | expect(node.reload.position).to eq index + 1 25 | end 26 | end 27 | 28 | context 'when I change position to lower' do 29 | before { reorder child_2, 1 } 30 | 31 | it 'moves node up' do 32 | assert_order child_2, child_1, child_3 33 | end 34 | end 35 | 36 | context 'when I change position to lower' do 37 | before { reorder child_2, 3 } 38 | 39 | it 'moves node down' do 40 | assert_order child_1, child_3, child_2 41 | end 42 | end 43 | 44 | context 'when I move highest node lower' do 45 | before { reorder child_1, 3 } 46 | 47 | it 'moves node down' do 48 | assert_order child_2, child_3, child_1 49 | end 50 | end 51 | 52 | context 'when I move lowest node upper' do 53 | before { reorder child_3, 1 } 54 | 55 | it 'moves node down' do 56 | assert_order child_3, child_1, child_2 57 | end 58 | end 59 | 60 | context 'when I move to very high position' do 61 | before { reorder child_1, 5 } 62 | 63 | it 'moves node to bottom' do 64 | assert_order child_2, child_3, child_1 65 | end 66 | end 67 | 68 | context 'when I move to zero position' do 69 | before { reorder child_2, 0 } 70 | 71 | it 'moves it to top' do 72 | assert_order child_2, child_1, child_3 73 | end 74 | end 75 | 76 | context 'when I move to same position' do 77 | before { reorder child_2, 2 } 78 | 79 | specify 'order remains the same' do 80 | assert_order child_1, child_2, child_3 81 | end 82 | end 83 | end -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['DB'] ||= 'pg' 2 | 3 | require 'rubygems' 4 | require 'bundler/setup' 5 | 6 | require 'rspec' 7 | require 'rspec/expectations' 8 | 9 | if ENV['COVERAGE'].to_i.nonzero? 10 | begin 11 | require 'simplecov' 12 | SimpleCov.command_name "rspec/#{File.basename(ENV['BUNDLE_GEMFILE'])}/#{ENV['DB']}" 13 | SimpleCov.start 'test_frameworks' do 14 | add_filter 'vendor/' 15 | end 16 | rescue LoadError 17 | #ignore 18 | end 19 | end 20 | 21 | require 'support/db/boot' 22 | 23 | require 'factory_girl' 24 | require 'support/factories' 25 | require 'support/matchers' 26 | require 'support/tree_factory' 27 | require 'database_cleaner' 28 | 29 | RSpec.configure do |config| 30 | config.include FactoryGirl::Syntax::Methods 31 | config.extend TreeFactory 32 | 33 | config.treat_symbols_as_metadata_keys_with_true_values = true 34 | 35 | config.around :each, :transactional do |example| 36 | DatabaseCleaner.strategy = :transaction 37 | 38 | DatabaseCleaner.start 39 | 40 | example.run 41 | 42 | DatabaseCleaner.clean 43 | end 44 | 45 | config.around :each, :non_transactional do |example| 46 | DatabaseCleaner.strategy = :truncation 47 | 48 | DatabaseCleaner.start 49 | 50 | example.run 51 | 52 | DatabaseCleaner.clean 53 | end 54 | end -------------------------------------------------------------------------------- /spec/support/db/boot.rb: -------------------------------------------------------------------------------- 1 | # This script establishes connection, creates DB schema and loads models definitions. 2 | # Used by both rspec and cucumber 3 | 4 | require 'acts_as_ordered_tree' 5 | require 'active_record' 6 | 7 | require 'logger' 8 | require 'yaml' 9 | require 'erb' 10 | 11 | base_dir = File.dirname(__FILE__) 12 | config_file = File.join(base_dir, ENV['DBCONF'] || 'config.yml') 13 | 14 | ActiveRecord::Base.configurations = YAML::load(ERB.new(IO.read(config_file)).result) 15 | ActiveRecord::Base.establish_connection(ENV['DB'].to_sym) 16 | ActiveRecord::Base.logger = Logger.new(ENV['DEBUG'] ? $stderr : '/dev/null') 17 | ActiveRecord::Migration.verbose = false 18 | I18n.enforce_available_locales = false if I18n.respond_to?(:enforce_available_locales=) 19 | 20 | load(File.join(base_dir, 'schema.rb')) 21 | 22 | require File.join(base_dir, '..', 'models') -------------------------------------------------------------------------------- /spec/support/db/config.travis.yml: -------------------------------------------------------------------------------- 1 | pg: 2 | adapter: postgresql 3 | username: postgres 4 | database: acts_as_ordered_tree_test 5 | allow_concurrency: true 6 | min_messages: ERROR 7 | mysql: 8 | adapter: mysql2 9 | database: acts_as_ordered_tree_test 10 | username: travis 11 | password: 12 | encoding: utf8 13 | sqlite3: 14 | adapter: sqlite3 15 | database: acts_as_ordered_tree.sqlite3.db -------------------------------------------------------------------------------- /spec/support/db/config.yml: -------------------------------------------------------------------------------- 1 | pg: 2 | adapter: postgresql 3 | database: acts_as_ordered_tree_test 4 | allow_concurrency: true 5 | min_messages: ERROR 6 | mysql: 7 | adapter: mysql2 8 | database: acts_as_ordered_tree_test 9 | username: root 10 | password: 11 | encoding: utf8 12 | sqlite3: 13 | adapter: <%= "jdbc" if defined? JRUBY_VERSION %>sqlite3 14 | database: acts_as_ordered_tree.sqlite3.db -------------------------------------------------------------------------------- /spec/support/db/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define(:version => 0) do 2 | create_table :categories, :force => true do |t| 3 | t.column :name, :string 4 | t.column :parent_id, :integer 5 | t.column :position, :integer 6 | t.column :depth, :integer 7 | t.column :categories_count, :integer 8 | end 9 | 10 | create_table :renamed_columns, :force => true do |t| 11 | t.column :name, :string 12 | t.column :mother_id, :integer 13 | t.column :red, :integer 14 | t.column :pitch, :integer 15 | end 16 | 17 | create_table :scoped, :force => true do |t| 18 | t.column :scope_type, :string 19 | t.column :name, :string 20 | t.column :parent_id, :integer 21 | t.column :position, :integer 22 | end 23 | 24 | create_table :sti_examples, :force => true do |t| 25 | t.column :type, :string, :null => false 26 | t.column :name, :string 27 | t.column :parent_id, :integer 28 | t.column :position, :integer 29 | t.column :depth, :integer 30 | t.column :children_count, :integer 31 | end 32 | end -------------------------------------------------------------------------------- /spec/support/factories.rb: -------------------------------------------------------------------------------- 1 | FactoryGirl.define do 2 | factory :default, :aliases => [:node] do 3 | sequence(:name) { |n| "category #{n}" } 4 | end 5 | 6 | factory :default_with_counter_cache do 7 | sequence(:name) { |n| "category #{n}" } 8 | end 9 | 10 | factory :default_without_depth do 11 | sequence(:name) { |n| "category #{n}" } 12 | end 13 | 14 | factory :scoped do 15 | sequence(:scope_type) { |n| "type_#{n}" } 16 | sequence(:name) { |n| "category #{n}" } 17 | end 18 | end -------------------------------------------------------------------------------- /spec/support/matchers.rb: -------------------------------------------------------------------------------- 1 | module RSpec::Matchers 2 | # it { expect{...}.to query_database.once } 3 | # it { expect{...}.to query_database.at_most(2).times } 4 | # it { expect{...}.not_to query_database } 5 | def query_database(regexp = nil) 6 | QueryDatabaseMatcher.new(regexp) 7 | end 8 | 9 | # example { expect(record1, record2, record3).to be_sorted } 10 | def be_sorted 11 | OrderMatcher.new 12 | end 13 | 14 | class QueryDatabaseMatcher 15 | def initialize(regexp) 16 | @min = nil 17 | @max = nil 18 | @regexp = regexp 19 | end 20 | 21 | def times 22 | self 23 | end 24 | alias time times 25 | 26 | def at_least(times) 27 | @min = times == :once ? 1 : times 28 | self 29 | end 30 | 31 | def at_most(times) 32 | @max = times == :once ? 1 : times 33 | self 34 | end 35 | 36 | def exactly(times) 37 | at_least(times).at_most(times) 38 | self 39 | end 40 | 41 | def once 42 | exactly(1) 43 | end 44 | 45 | def twice 46 | exactly(2) 47 | end 48 | 49 | def matches?(subject) 50 | record_queries { subject.call } 51 | 52 | result = expected_queries_count.include?(@queries.size) 53 | result &&= @queries.any? { |q| @regexp === q } if @regexp 54 | result 55 | end 56 | 57 | def description 58 | desc = 'query database' 59 | 60 | if @min && !@max 61 | desc << ' at least ' << human_readable_count(@min) 62 | end 63 | 64 | if @max && !@min 65 | desc << ' at most ' << human_readable_count(@max) 66 | end 67 | 68 | if @min && @max && @min != @max 69 | desc << " #{@min}..#{@max} times" 70 | end 71 | 72 | if @min && @max && @min == @max 73 | desc << ' ' << human_readable_count(@min) 74 | end 75 | 76 | if @regexp 77 | desc << ' and match ' << @regexp.to_s 78 | end 79 | 80 | desc 81 | end 82 | 83 | def failure_message_for_should(negative = false) 84 | verb = negative ? 'not to' : 'to' 85 | message = "expected given block #{verb} #{description}, but #{@queries.size} queries sent" 86 | 87 | if @queries.any? 88 | message << ":\n#{@queries.each_with_index.map { |q, i| "#{i+1}. #{q}"}.join("\n")}" 89 | end 90 | 91 | message 92 | end 93 | 94 | def failure_message_for_should_not 95 | failure_message_for_should(true) 96 | end 97 | 98 | def supports_block_expectations? 99 | true 100 | end 101 | 102 | private 103 | def record_queries 104 | @queries = [] 105 | 106 | subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |*, sql| 107 | next if sql[:name] == 'SCHEMA' 108 | 109 | @queries << sql[:sql] 110 | end 111 | 112 | yield 113 | ensure 114 | ActiveSupport::Notifications.unsubscribe(subscriber) 115 | end 116 | 117 | def expected_queries_count 118 | ((@min||1)..@max || 10000) 119 | end 120 | 121 | def human_readable_count(n) 122 | n == 1 ? 'once' : "#{n} times" 123 | end 124 | end 125 | 126 | class OrderMatcher 127 | def matches?(*records) 128 | @records = Array.wrap(records).flatten 129 | 130 | @records.sort_by { |record| record.reload.ordered_tree_node.position } == @records 131 | end 132 | 133 | def failure_message_for_should 134 | "expected #{@records.inspect} to be ordered by position, but they are not" 135 | end 136 | 137 | def failure_message_for_should_not 138 | "expected #{@records.inspect} not to be ordered by position, but they are" 139 | end 140 | end 141 | end 142 | 143 | # Taken from rspec-rails 144 | module ::ActiveModel::Validations 145 | # Extension to enhance `should have` on AR Model instances. Calls 146 | # model.valid? in order to prepare the object's errors object. 147 | # 148 | # You can also use this to specify the content of the error messages. 149 | # 150 | # @example 151 | # 152 | # model.should have(:no).errors_on(:attribute) 153 | # model.should have(1).error_on(:attribute) 154 | # model.should have(n).errors_on(:attribute) 155 | # 156 | # model.errors_on(:attribute).should include("can't be blank") 157 | def errors_on(attribute) 158 | self.valid? 159 | [self.errors[attribute]].flatten.compact 160 | end 161 | alias :error_on :errors_on 162 | end -------------------------------------------------------------------------------- /spec/support/models.rb: -------------------------------------------------------------------------------- 1 | class Default < ActiveRecord::Base 2 | self.table_name = 'categories' 3 | 4 | default_scope { where('1=1') } 5 | 6 | acts_as_ordered_tree 7 | end 8 | 9 | class RenamedColumns < ActiveRecord::Base 10 | acts_as_ordered_tree :parent_column => :mother_id, 11 | :position_column => :red, 12 | :depth_column => :pitch 13 | 14 | default_scope { where('1=1') } 15 | end 16 | 17 | class DefaultWithCounterCache < ActiveRecord::Base 18 | self.table_name = 'categories' 19 | 20 | acts_as_ordered_tree :counter_cache => :categories_count 21 | 22 | default_scope { where('1=1') } 23 | end 24 | 25 | class DefaultWithoutDepth < ActiveRecord::Base 26 | self.table_name = 'categories' 27 | 28 | acts_as_ordered_tree :depth_column => false 29 | end 30 | 31 | class Scoped < ActiveRecord::Base 32 | self.table_name = 'scoped' 33 | 34 | default_scope { where('1=1') } 35 | 36 | acts_as_ordered_tree :scope => :scope_type 37 | end 38 | 39 | class StiExample < ActiveRecord::Base 40 | acts_as_ordered_tree :counter_cache => :children_count 41 | end -------------------------------------------------------------------------------- /spec/tree/children_association_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | # All these examples throw deprecation errors with Rails 4.2 6 | describe ActsAsOrderedTree::Tree::ChildrenAssociation, :transactional do 7 | shared_examples 'ChildrenAssociation' do |model| 8 | describe model.to_s do 9 | around(:each) do |example| 10 | ActiveSupport::Deprecation.silence(&example) 11 | end 12 | 13 | tree :factory => model do 14 | root { 15 | child_1 16 | child_2 17 | child_3 18 | } 19 | end 20 | 21 | let(:klass) { current_tree } 22 | 23 | describe 'joining to association' do 24 | let(:relation) { klass.joins(:children) } 25 | 26 | it { expect(relation).to eq [root, root, root] } 27 | end 28 | 29 | describe 'loading association' do 30 | it { expect(root.children.size).to eq 3 } 31 | it { expect(root.children).to eq [child_1, child_2, child_3] } 32 | 33 | it { expect{root.children.to_a}.to query_database(/ORDER BY .*position/) } 34 | end 35 | 36 | describe 'eager_loading association' do 37 | let(:relation) { klass.eager_load(:children).order(klass.arel_table[:id]) } 38 | let(:first) { relation.to_a.first } 39 | 40 | it { expect(relation).to eq [root, child_1, child_2, child_3] } 41 | it { expect(first.children).to be_loaded } 42 | it { expect(first.children.size).to eq 3 } 43 | end 44 | 45 | describe 'preloading association' do 46 | let(:relation) { klass.preload(:children).order(klass.arel_table[:id]) } 47 | let(:first) { relation.to_a.first } 48 | 49 | it { expect(relation).to eq [root, child_1, child_2, child_3] } 50 | it { expect(first.children).to be_loaded } 51 | it { expect(first.children.size).to eq 3 } 52 | end 53 | 54 | describe 'preloading association (via includes method)' do 55 | let(:relation) { klass.includes(:children).order(klass.arel_table[:id]) } 56 | let(:first) { relation.to_a.first } 57 | 58 | it { expect(relation).to eq [root, child_1, child_2, child_3] } 59 | it { expect(first.children).to be_loaded } 60 | it { expect(first.children.size).to eq 3 } 61 | end 62 | 63 | describe 'extensions' do 64 | it { expect(root.children).to respond_to :each_with_level } 65 | it { expect(root.children).to respond_to :each_without_orphans } 66 | end 67 | end 68 | end 69 | 70 | include_examples 'ChildrenAssociation', :default 71 | include_examples 'ChildrenAssociation', :scoped 72 | end -------------------------------------------------------------------------------- /spec/tree/columns_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | require 'acts_as_ordered_tree/tree' 6 | 7 | describe ActsAsOrderedTree::Tree::Columns do 8 | let(:klass) { Default } 9 | 10 | shared_examples 'ordered tree column' do |method, option_name, value, klass=Default| 11 | context 'when existing column name given' do 12 | subject(:columns) { described_class.new(klass, option_name => value) } 13 | 14 | it { expect(columns.send(method)).to eq value.to_s } 15 | it { expect(columns.send("#{method}?")).to be true } 16 | end 17 | 18 | context 'when column name given but klass.columns_hash does not contain given name' do 19 | subject(:columns) { described_class.new(klass, method => :x) } 20 | 21 | it { expect(columns.send(method)).to be nil } 22 | it { expect(columns.send("#{method}?")).to be false } 23 | end 24 | 25 | context 'when column name not given' do 26 | subject(:columns) { described_class.new(klass, {}) } 27 | 28 | it { expect(columns.send(method)).to be nil } 29 | it { expect(columns.send("#{method}?")).to be false } 30 | end 31 | 32 | context 'when false given' do 33 | subject(:columns) { described_class.new(klass, option_name => false) } 34 | 35 | it { expect(columns.send(method)).to be nil } 36 | it { expect(columns.send("#{method}?")).to be false } 37 | end 38 | end 39 | 40 | include_examples 'ordered tree column', :parent, :parent_column, :parent_id 41 | include_examples 'ordered tree column', :position, :position_column, :position 42 | include_examples 'ordered tree column', :depth, :depth_column, :depth 43 | include_examples 'ordered tree column', :counter_cache, :counter_cache, :categories_count do 44 | context 'when true value as column name given' do 45 | class Category < ActiveRecord::Base 46 | end 47 | 48 | subject(:columns) { described_class.new(Category, :counter_cache => true) } 49 | 50 | it { expect(columns.counter_cache).to eq 'categories_count' } 51 | end 52 | end 53 | 54 | describe 'scope columns' do 55 | it 'raises error when any unknown column given' do 56 | expect{described_class.new(Scoped, :scope => :x)}.to raise_error(described_class::UnknownColumn) 57 | end 58 | 59 | it 'returns array' do 60 | subject = described_class.new(Scoped, :scope => :scope_type) 61 | 62 | expect(subject.scope).to eq %w(scope_type) 63 | end 64 | end 65 | end -------------------------------------------------------------------------------- /spec/tree/scopes_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'spec_helper' 4 | 5 | describe ActsAsOrderedTree::Tree::Scopes, :transactional do 6 | shared_examples 'ActsAsOrderedTree scopes' do |model, attrs = {}| 7 | describe model.to_s do 8 | tree :factory => model, :attributes => attrs do 9 | root_1 { 10 | child_1 { 11 | grandchild_1 12 | } 13 | } 14 | root_2 { 15 | child_2 { 16 | grandchild_2 17 | } 18 | } 19 | end 20 | 21 | describe '.leaves' do 22 | it { expect(current_tree.leaves.order(:id)).to eq [grandchild_1, grandchild_2] } 23 | it { expect(root_1.descendants.leaves).to eq [grandchild_1] } 24 | end 25 | 26 | describe '.roots' do 27 | it { expect(current_tree.roots).to eq [root_1, root_2] } 28 | end 29 | 30 | describe '.root' do 31 | it { expect(current_tree.root).to eq root_1 } 32 | end 33 | end 34 | end 35 | 36 | include_examples 'ActsAsOrderedTree scopes', :default 37 | include_examples 'ActsAsOrderedTree scopes', :default_with_counter_cache 38 | include_examples 'ActsAsOrderedTree scopes', :scoped 39 | end --------------------------------------------------------------------------------