├── init.rb ├── .coveralls.yml ├── lib ├── ancestry │ ├── version.rb │ ├── exceptions.rb │ ├── materialized_path.rb │ ├── has_ancestry.rb │ ├── class_methods.rb │ └── instance_methods.rb └── ancestry.rb ├── .gitignore ├── install.rb ├── Gemfile ├── gemfiles ├── sqlite3_ar_32.gemfile ├── sqlite3_ar_42.gemfile ├── sqlite3_ar_50.gemfile ├── sqlite3_ar_51.gemfile ├── mysql2_ar_50.gemfile ├── mysql2_ar_51.gemfile ├── mysql_ar_32.gemfile ├── mysql_ar_42.gemfile ├── pg_ar_32.gemfile ├── pg_ar_42.gemfile ├── pg_ar_50.gemfile └── pg_ar_51.gemfile ├── test ├── concerns │ ├── db_test.rb │ ├── validations_test.rb │ ├── build_ancestry_test.rb │ ├── sti_support_test.rb │ ├── tree_predicate_test.rb │ ├── sort_by_ancestry_test.rb │ ├── default_scopes_test.rb │ ├── depth_constraints_test.rb │ ├── scopes_test.rb │ ├── depth_caching_test.rb │ ├── orphan_strategies_test.rb │ ├── touching_test.rb │ ├── integrity_checking_and_restoration_test.rb │ ├── has_ancestry_test.rb │ ├── tree_navigration_test.rb │ └── arrangement_test.rb ├── database.ci.yml ├── database.example.yml └── environment.rb ├── Appraisals ├── Rakefile ├── .travis.yml ├── MIT-LICENSE ├── ancestry.gemspec ├── CHANGELOG.md └── README.md /init.rb: -------------------------------------------------------------------------------- 1 | require 'ancestry' -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-ci -------------------------------------------------------------------------------- /lib/ancestry/version.rb: -------------------------------------------------------------------------------- 1 | module Ancestry 2 | VERSION = "3.0.0" 3 | end 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | doc 2 | *.gem 3 | **/*.log 4 | **/*.db 5 | test/database.yml 6 | coverage 7 | Gemfile.lock 8 | gemfiles/*.lock 9 | -------------------------------------------------------------------------------- /install.rb: -------------------------------------------------------------------------------- 1 | puts "Thank you for installing Ancestry. You can visit http://github.com/stefankroes/ancestry to read the documentation." 2 | -------------------------------------------------------------------------------- /lib/ancestry/exceptions.rb: -------------------------------------------------------------------------------- 1 | module Ancestry 2 | class AncestryException < RuntimeError 3 | end 4 | 5 | class AncestryIntegrityException < AncestryException 6 | end 7 | end -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem "appraisal" 6 | gem "rdoc" 7 | gem "coveralls", require: false 8 | gem "activerecord", '~> 4.0.2' 9 | 10 | # RUBY_VERSION < "2.0" 11 | gem "json", "~> 1.8.3" 12 | gem "term-ansicolor", "~> 1.3.2" 13 | gem "tins", "~> 1.6.0" 14 | -------------------------------------------------------------------------------- /lib/ancestry.rb: -------------------------------------------------------------------------------- 1 | require_relative 'ancestry/class_methods' 2 | require_relative 'ancestry/instance_methods' 3 | require_relative 'ancestry/exceptions' 4 | require_relative 'ancestry/has_ancestry' 5 | require_relative 'ancestry/materialized_path' 6 | 7 | module Ancestry 8 | ANCESTRY_PATTERN = /\A[0-9]+(\/[0-9]+)*\Z/ 9 | end 10 | -------------------------------------------------------------------------------- /gemfiles/sqlite3_ar_32.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "rdoc" 7 | gem "coveralls", :require => false 8 | gem "activerecord", "3.2.22.5" 9 | gem "json", "~> 1.8.3" 10 | gem "term-ansicolor", "~> 1.3.2" 11 | gem "tins", "~> 1.6.0" 12 | 13 | gemspec :path => "../" 14 | -------------------------------------------------------------------------------- /gemfiles/sqlite3_ar_42.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "rdoc" 7 | gem "coveralls", :require => false 8 | gem "activerecord", "4.2.7.1" 9 | gem "json", "~> 1.8.3" 10 | gem "term-ansicolor", "~> 1.3.2" 11 | gem "tins", "~> 1.6.0" 12 | 13 | gemspec :path => "../" 14 | -------------------------------------------------------------------------------- /gemfiles/sqlite3_ar_50.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "rdoc" 7 | gem "coveralls", :require => false 8 | gem "activerecord", "5.0.2" 9 | gem "json", "~> 1.8.3" 10 | gem "term-ansicolor", "~> 1.3.2" 11 | gem "tins", "~> 1.6.0" 12 | 13 | gemspec :path => "../" 14 | -------------------------------------------------------------------------------- /gemfiles/sqlite3_ar_51.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "rdoc" 7 | gem "coveralls", :require => false 8 | gem "activerecord", "5.1.0" 9 | gem "json", "~> 1.8.3" 10 | gem "term-ansicolor", "~> 1.3.2" 11 | gem "tins", "~> 1.6.0" 12 | 13 | gemspec :path => "../" 14 | -------------------------------------------------------------------------------- /test/concerns/db_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../environment' 2 | 3 | class DbTest < ActiveSupport::TestCase 4 | def test_does_not_load_database 5 | c = Class.new(ActiveRecord::Base) do 6 | def self.connection 7 | raise "Oh No - tried to connect to database" 8 | end 9 | end 10 | 11 | c.send(:has_ancestry) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /gemfiles/mysql2_ar_50.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "rdoc" 7 | gem "coveralls", :require => false 8 | gem "activerecord", "5.0.2" 9 | gem "json", "~> 1.8.3" 10 | gem "term-ansicolor", "~> 1.3.2" 11 | gem "tins", "~> 1.6.0" 12 | gem "mysql2" 13 | 14 | gemspec :path => "../" 15 | -------------------------------------------------------------------------------- /gemfiles/mysql2_ar_51.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "rdoc" 7 | gem "coveralls", :require => false 8 | gem "activerecord", "5.1.0" 9 | gem "json", "~> 1.8.3" 10 | gem "term-ansicolor", "~> 1.3.2" 11 | gem "tins", "~> 1.6.0" 12 | gem "mysql2" 13 | 14 | gemspec :path => "../" 15 | -------------------------------------------------------------------------------- /gemfiles/mysql_ar_32.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "rdoc" 7 | gem "coveralls", :require => false 8 | gem "activerecord", "3.2.22.5" 9 | gem "json", "~> 1.8.3" 10 | gem "term-ansicolor", "~> 1.3.2" 11 | gem "tins", "~> 1.6.0" 12 | gem "mysql" 13 | 14 | gemspec :path => "../" 15 | -------------------------------------------------------------------------------- /gemfiles/mysql_ar_42.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "rdoc" 7 | gem "coveralls", :require => false 8 | gem "activerecord", "4.2.7.1" 9 | gem "json", "~> 1.8.3" 10 | gem "term-ansicolor", "~> 1.3.2" 11 | gem "tins", "~> 1.6.0" 12 | gem "mysql" 13 | 14 | gemspec :path => "../" 15 | -------------------------------------------------------------------------------- /gemfiles/pg_ar_32.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "rdoc" 7 | gem "coveralls", :require => false 8 | gem "activerecord", "3.2.22.5" 9 | gem "json", "~> 1.8.3" 10 | gem "term-ansicolor", "~> 1.3.2" 11 | gem "tins", "~> 1.6.0" 12 | gem "pg", "0.18.4" 13 | 14 | gemspec :path => "../" 15 | -------------------------------------------------------------------------------- /gemfiles/pg_ar_42.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "rdoc" 7 | gem "coveralls", :require => false 8 | gem "activerecord", "4.2.7.1" 9 | gem "json", "~> 1.8.3" 10 | gem "term-ansicolor", "~> 1.3.2" 11 | gem "tins", "~> 1.6.0" 12 | gem "pg", "0.18.4" 13 | 14 | gemspec :path => "../" 15 | -------------------------------------------------------------------------------- /gemfiles/pg_ar_50.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "rdoc" 7 | gem "coveralls", :require => false 8 | gem "activerecord", "5.0.2" 9 | gem "json", "~> 1.8.3" 10 | gem "term-ansicolor", "~> 1.3.2" 11 | gem "tins", "~> 1.6.0" 12 | gem "pg", "0.18.4" 13 | 14 | gemspec :path => "../" 15 | -------------------------------------------------------------------------------- /gemfiles/pg_ar_51.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "rdoc" 7 | gem "coveralls", :require => false 8 | gem "activerecord", "5.1.0" 9 | gem "json", "~> 1.8.3" 10 | gem "term-ansicolor", "~> 1.3.2" 11 | gem "tins", "~> 1.6.0" 12 | gem "pg", "0.18.4" 13 | 14 | gemspec :path => "../" 15 | -------------------------------------------------------------------------------- /test/database.ci.yml: -------------------------------------------------------------------------------- 1 | sqlite3: 2 | adapter: sqlite3 3 | database: ":memory:" 4 | timeout: 500 5 | pg: 6 | adapter: postgresql 7 | database: ancestry_test 8 | username: postgres 9 | min_messages: WARNING 10 | mysql: 11 | adapter: mysql 12 | database: ancestry_test 13 | username: travis 14 | encoding: utf8 15 | mysql2: 16 | adapter: mysql2 17 | database: ancestry_test 18 | username: travis 19 | encoding: utf8 20 | -------------------------------------------------------------------------------- /test/database.example.yml: -------------------------------------------------------------------------------- 1 | sqlite3: 2 | adapter: sqlite3 3 | database: ":memory:" 4 | pg: 5 | adapter: postgresql 6 | database: ancestry_test 7 | username: ancestry 8 | password: ancestry 9 | min_messages: WARNING 10 | mysql: 11 | adapter: mysql 12 | host: localhost 13 | database: ancestry_test 14 | username: ancestry 15 | password: ancestry 16 | mysql2: 17 | adapter: mysql2 18 | host: localhost 19 | database: ancestry_test 20 | username: ancestry 21 | password: ancestry 22 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | %w(mysql pg sqlite3).each do |db_type| 2 | %w(3.2.22.5 4.2.7.1 5.0.2 5.1.0).each do |ar_version| 3 | # rails 5.0 only supports 'mysql2' driver 4 | # rails 4.2 supports both 5 | db_gem = db_type 6 | db_gem = "mysql2" if ar_version >= "5.0" && db_type == "mysql" 7 | appraise "#{db_gem}-ar-#{ar_version.split('.').first(2).join}" do 8 | gem "activerecord", ar_version 9 | gem db_gem if db_type == "mysql" 10 | gem "pg", "0.18.4" if db_type == "pg" 11 | # Skip sqlite3 since it's part of the base Gemfile. 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'bundler/gem_tasks' 3 | require 'rake/testtask' 4 | require 'yard/rake/yardoc_task' 5 | 6 | desc 'Default: run unit tests.' 7 | task :default => :test 8 | 9 | desc 'Test the ancestry plugin.' 10 | Rake::TestTask.new(:test) do |t| 11 | t.libs << 'test' 12 | t.pattern = 'test/**/*_test.rb' 13 | t.verbose = true 14 | end 15 | 16 | desc 'Generate documentation for the ancestry plugin.' 17 | YARD::Rake::YardocTask.new do |t| 18 | t.files = ['README.rdoc', 'lib/**/*.rb'] 19 | t.options = ['--any', '--extra', '--opts'] # optional 20 | end 21 | 22 | task :doc => :yard 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | rvm: 4 | - 2.2.6 5 | - 2.3.3 6 | - 2.4.1 7 | 8 | gemfile: 9 | - gemfiles/mysql_ar_42.gemfile 10 | - gemfiles/mysql2_ar_50.gemfile 11 | - gemfiles/mysql2_ar_51.gemfile 12 | - gemfiles/pg_ar_42.gemfile 13 | - gemfiles/pg_ar_50.gemfile 14 | - gemfiles/pg_ar_51.gemfile 15 | - gemfiles/sqlite3_ar_42.gemfile 16 | - gemfiles/sqlite3_ar_50.gemfile 17 | - gemfiles/sqlite3_ar_51.gemfile 18 | 19 | matrix: 20 | include: 21 | - rvm: 1.9.3 22 | gemfile: gemfiles/mysql_ar_32.gemfile 23 | - rvm: 1.9.3 24 | gemfile: gemfiles/pg_ar_32.gemfile 25 | - rvm: 1.9.3 26 | gemfile: gemfiles/sqlite3_ar_32.gemfile 27 | exclude: 28 | - rvm: 2.4.1 29 | gemfile: gemfiles/mysql_ar_42.gemfile 30 | 31 | services: 32 | - mysql 33 | - postgresql 34 | 35 | before_script: 36 | - mysql -e 'create database ancestry_test;' || true 37 | - psql -c 'create database ancestry_test;' -U postgres || true 38 | -------------------------------------------------------------------------------- /test/concerns/validations_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../environment' 2 | 3 | class ValidationsTest < ActiveSupport::TestCase 4 | def test_ancestry_column_validation 5 | AncestryTestDatabase.with_model do |model| 6 | node = model.create 7 | ['3', '10/2', '1/4/30', nil].each do |value| 8 | node.send :write_attribute, model.ancestry_column, value 9 | node.valid?; assert node.errors[model.ancestry_column].blank? 10 | end 11 | ['1/3/', '/2/3', 'a', 'a/b', '-34', '/54'].each do |value| 12 | node.send :write_attribute, model.ancestry_column, value 13 | node.valid?; assert !node.errors[model.ancestry_column].blank? 14 | end 15 | end 16 | end 17 | 18 | def test_validate_ancestry_exclude_self 19 | AncestryTestDatabase.with_model do |model| 20 | parent = model.create! 21 | child = parent.children.create! 22 | assert_raise ActiveRecord::RecordInvalid do 23 | parent.update_attributes! :parent => child 24 | end 25 | end 26 | end 27 | end -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Stefan Kroes 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /test/concerns/build_ancestry_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../environment' 2 | 3 | class BuildAncestryTest < ActiveSupport::TestCase 4 | def test_build_ancestry_from_parent_ids 5 | AncestryTestDatabase.with_model :skip_ancestry => true, :extra_columns => {:parent_id => :integer} do |model| 6 | [model.create!].each do |parent1| 7 | (Array.new(5) { model.create! :parent_id => parent1.id }).each do |parent2| 8 | (Array.new(5) { model.create! :parent_id => parent2.id }).each do |parent3| 9 | (Array.new(5) { model.create! :parent_id => parent3.id }) 10 | end 11 | end 12 | end 13 | 14 | # Assert all nodes where created 15 | assert_equal (0..3).map { |n| 5 ** n }.sum, model.count 16 | 17 | model.has_ancestry 18 | model.build_ancestry_from_parent_ids! 19 | 20 | # Assert ancestry integrity 21 | assert_nothing_raised do 22 | model.check_ancestry_integrity! 23 | end 24 | 25 | roots = model.roots.to_a 26 | # Assert single root node 27 | assert_equal 1, roots.size 28 | 29 | # Assert it has 5 children 30 | roots.each do |parent1| 31 | assert_equal 5, parent1.children.count 32 | parent1.children.each do |parent2| 33 | assert_equal 5, parent2.children.count 34 | parent2.children.each do |parent3| 35 | assert_equal 5, parent3.children.count 36 | parent3.children.each do |parent4| 37 | assert_equal 0, parent4.children.count 38 | end 39 | end 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/concerns/sti_support_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../environment' 2 | 3 | class StiSupportTest < ActiveSupport::TestCase 4 | def test_sti_support 5 | AncestryTestDatabase.with_model :extra_columns => {:type => :string} do |model| 6 | subclass1 = Object.const_set 'Subclass1', Class.new(model) 7 | (class << subclass1; self; end).send :define_method, :model_name do; Struct.new(:human, :underscore).new 'Subclass1', 'subclass1'; end 8 | subclass2 = Object.const_set 'Subclass2', Class.new(model) 9 | (class << subclass2; self; end).send :define_method, :model_name do; Struct.new(:human, :underscore).new 'Subclass1', 'subclass1'; end 10 | 11 | node1 = subclass1.create! 12 | node2 = subclass2.create! :parent => node1 13 | node3 = subclass1.create! :parent => node2 14 | node4 = subclass2.create! :parent => node3 15 | node5 = subclass1.create! :parent => node4 16 | 17 | model.all.each do |node| 18 | assert [subclass1, subclass2].include?(node.class) 19 | end 20 | 21 | assert_equal [node2.id, node3.id, node4.id, node5.id], node1.descendants.map(&:id) 22 | assert_equal [node1.id, node2.id, node3.id, node4.id, node5.id], node1.subtree.map(&:id) 23 | assert_equal [node1.id, node2.id, node3.id, node4.id], node5.ancestors.map(&:id) 24 | assert_equal [node1.id, node2.id, node3.id, node4.id, node5.id], node5.path.map(&:id) 25 | end 26 | end 27 | 28 | def test_sti_support_with_from_subclass 29 | AncestryTestDatabase.with_model :extra_columns => {:type => :string} do |model| 30 | subclass1 = Object.const_set 'SubclassWithAncestry', Class.new(model) 31 | 32 | subclass1.has_ancestry 33 | 34 | subclass1.create! 35 | end 36 | end 37 | end -------------------------------------------------------------------------------- /test/concerns/tree_predicate_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../environment' 2 | 3 | class TreePredicateTest < ActiveSupport::TestCase 4 | def test_tree_predicates 5 | AncestryTestDatabase.with_model :depth => 2, :width => 3 do |model, roots| 6 | roots.each do |lvl0_node, lvl0_children| 7 | root, children = lvl0_node, lvl0_children.map(&:first) 8 | # Ancestors assertions 9 | assert children.map { |n| root.ancestor_of?(n) }.all? 10 | assert children.map { |n| !n.ancestor_of?(root) }.all? 11 | # Parent assertions 12 | assert children.map { |n| root.parent_of?(n) }.all? 13 | assert children.map { |n| !n.parent_of?(root) }.all? 14 | # Root assertions 15 | assert root.is_root? 16 | assert children.map { |n| !n.is_root? }.all? 17 | assert children.map { |n| root.root_of?(n) }.all? 18 | assert children.map { |n| !n.root_of?(root) }.all? 19 | # Children assertions 20 | assert root.has_children? 21 | assert !root.is_childless? 22 | assert children.map { |n| n.is_childless? }.all? 23 | assert children.map { |n| !root.child_of?(n) }.all? 24 | assert children.map { |n| n.child_of?(root) }.all? 25 | # Siblings assertions 26 | assert root.has_siblings? 27 | assert !root.is_only_child? 28 | assert children.map { |n| !n.is_only_child? }.all? 29 | assert children.map { |n| !root.sibling_of?(n) }.all? 30 | assert children.permutation(2).map { |l, r| l.sibling_of?(r) }.all? 31 | # Descendants assertions 32 | assert children.map { |n| !root.descendant_of?(n) }.all? 33 | assert children.map { |n| n.descendant_of?(root) }.all? 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/concerns/sort_by_ancestry_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../environment' 2 | 3 | class SortByAncestryTest < ActiveSupport::TestCase 4 | def test_sort_by_ancestry 5 | AncestryTestDatabase.with_model do |model| 6 | # - n1 7 | # - n2 8 | # - n3 9 | # - n4 10 | # - n5 11 | n1 = model.create! 12 | n2 = model.create!(:parent => n1) 13 | n3 = model.create!(:parent => n2) 14 | n4 = model.create!(:parent => n2) 15 | n5 = model.create!(:parent => n1) 16 | 17 | records = model.sort_by_ancestry(model.all.sort_by(&:id).reverse) 18 | if records[1] == n2 19 | if records[2] == n3 20 | assert_equal [n1, n2, n3, n4, n5].map(&:id), records.map(&:id) 21 | else 22 | assert_equal [n1, n2, n4, n3, n5].map(&:id), records.map(&:id) 23 | end 24 | else 25 | if records[3] == n3 26 | assert_equal [n1, n5, n2, n3, n4].map(&:id), records.map(&:id) 27 | else 28 | assert_equal [n1, n5, n2, n4, n3].map(&:id), records.map(&:id) 29 | end 30 | end 31 | end 32 | end 33 | 34 | def test_sort_by_ancestry_with_block 35 | AncestryTestDatabase.with_model :extra_columns => {:rank => :integer} do |model| 36 | n1 = model.create!(:rank => 0) 37 | n2 = model.create!(:rank => 1) 38 | n3 = model.create!(:rank => 0, :parent => n1) 39 | n4 = model.create!(:rank => 0, :parent => n2) 40 | n5 = model.create!(:rank => 1, :parent => n1) 41 | n6 = model.create!(:rank => 1, :parent => n2) 42 | 43 | records = model.sort_by_ancestry(model.all.sort_by(&:rank).reverse) {|a, b| a.rank <=> b.rank} 44 | assert_equal [n1, n3, n5, n2, n4, n6].map(&:id), records.map(&:id) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/ancestry/materialized_path.rb: -------------------------------------------------------------------------------- 1 | module Ancestry 2 | module MaterializedPath 3 | def self.extended(base) 4 | base.validates_format_of base.ancestry_column, :with => Ancestry::ANCESTRY_PATTERN, :allow_nil => true 5 | base.send(:include, InstanceMethods) 6 | end 7 | 8 | def root_conditions 9 | arel_table[ancestry_column].eq(nil) 10 | end 11 | 12 | def ancestor_conditions(object) 13 | t = arel_table 14 | node = to_node(object) 15 | t[primary_key].in(node.ancestor_ids) 16 | end 17 | 18 | def path_conditions(object) 19 | t = arel_table 20 | node = to_node(object) 21 | t[primary_key].in(node.path_ids) 22 | end 23 | 24 | def child_conditions(object) 25 | t = arel_table 26 | node = to_node(object) 27 | t[ancestry_column].eq(node.child_ancestry) 28 | end 29 | 30 | def descendant_conditions(object) 31 | t = arel_table 32 | node = to_node(object) 33 | # rails has case sensitive matching. 34 | if ActiveRecord::VERSION::MAJOR >= 5 35 | t[ancestry_column].matches("#{node.child_ancestry}/%", nil, true).or(t[ancestry_column].eq(node.child_ancestry)) 36 | else 37 | t[ancestry_column].matches("#{node.child_ancestry}/%").or(t[ancestry_column].eq(node.child_ancestry)) 38 | end 39 | end 40 | 41 | def subtree_conditions(object) 42 | t = arel_table 43 | node = to_node(object) 44 | descendant_conditions(object).or(t[primary_key].eq(node.id)) 45 | end 46 | 47 | def sibling_conditions(object) 48 | t = arel_table 49 | node = to_node(object) 50 | t[ancestry_column].eq(node[ancestry_column]) 51 | end 52 | 53 | module InstanceMethods 54 | # Validates the ancestry, but can also be applied if validation is bypassed to determine if children should be affected 55 | def sane_ancestry? 56 | ancestry_value = read_attribute(self.ancestry_base_class.ancestry_column) 57 | ancestry_value.nil? || (ancestry_value.to_s =~ Ancestry::ANCESTRY_PATTERN && !ancestor_ids.include?(self.id)) 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /ancestry.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib/', __FILE__) 2 | $:.unshift lib unless $:.include?(lib) 3 | require 'ancestry/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'ancestry' 7 | s.summary = 'Organize ActiveRecord model into a tree structure' 8 | s.description = <<-EOF 9 | Ancestry allows the records of a ActiveRecord model to be organized in a tree 10 | structure, using a single, intuitively formatted database column. It exposes 11 | all the standard tree structure relations (ancestors, parent, root, children, 12 | siblings, descendants) and all of them can be fetched in a single sql query. 13 | Additional features are named_scopes, integrity checking, integrity restoration, 14 | arrangement of (sub)tree into hashes and different strategies for dealing with 15 | orphaned records. 16 | EOF 17 | s.metadata = { 18 | "homepage_uri" => "https://github.com/stefankroes/ancestry", 19 | "changelog_uri" => "https://github.com/stefankroes/ancestry/blob/master/CHANGELOG.md", 20 | "source_code_uri" => "https://github.com/stefankroes/ancestry/", 21 | "bug_tracker_uri" => "https://github.com/stefankroes/ancestry/issues", 22 | } 23 | s.version = Ancestry::VERSION 24 | 25 | s.authors = ['Stefan Kroes', 'Keenan Brock'] 26 | s.email = 'keenan@thebrocks.net' 27 | s.homepage = 'http://github.com/stefankroes/ancestry' 28 | s.license = 'MIT' 29 | 30 | s.files = [ 31 | 'ancestry.gemspec', 32 | 'init.rb', 33 | 'install.rb', 34 | 'lib/ancestry.rb', 35 | 'lib/ancestry/has_ancestry.rb', 36 | 'lib/ancestry/exceptions.rb', 37 | 'lib/ancestry/class_methods.rb', 38 | 'lib/ancestry/instance_methods.rb', 39 | 'lib/ancestry/materialized_path.rb', 40 | 'MIT-LICENSE', 41 | 'README.md' 42 | ] 43 | 44 | s.required_ruby_version = '>= 1.8.7' 45 | s.add_runtime_dependency 'activerecord', '>= 3.2.0' 46 | s.add_development_dependency 'yard' 47 | s.add_development_dependency 'rake', '~> 10.0' 48 | s.add_development_dependency 'test-unit' 49 | s.add_development_dependency 'minitest' 50 | s.add_development_dependency 'sqlite3' 51 | end 52 | -------------------------------------------------------------------------------- /test/concerns/default_scopes_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../environment' 2 | 3 | class DefaultScopesTest < ActiveSupport::TestCase 4 | def test_node_excluded_by_default_scope_should_still_move_with_parent 5 | AncestryTestDatabase.with_model( 6 | :width => 3, :depth => 3, :extra_columns => {:deleted_at => :datetime}, 7 | :default_scope_params => {:deleted_at => nil} 8 | ) do |model, roots| 9 | roots = model.roots.to_a 10 | grandparent = roots[0] 11 | new_grandparent = roots[1] 12 | parent = grandparent.children.first 13 | child = parent.children.first 14 | 15 | child.update_attributes :deleted_at => Time.now 16 | parent.update_attributes :parent => new_grandparent 17 | child.update_attributes :deleted_at => nil 18 | 19 | assert child.reload.ancestors.include? new_grandparent 20 | assert_equal new_grandparent, child.reload.ancestors.first 21 | assert_equal parent, child.reload.ancestors.to_a.last 22 | end 23 | end 24 | 25 | def test_node_excluded_by_default_scope_should_be_destroyed_with_parent 26 | AncestryTestDatabase.with_model( 27 | :width => 1, :depth => 2, :extra_columns => {:deleted_at => :datetime}, 28 | :default_scope_params => {:deleted_at => nil}, 29 | :orphan_strategy => :destroy 30 | ) do |model, roots| 31 | parent = model.roots.first 32 | child = parent.children.first 33 | 34 | child.update_attributes :deleted_at => Time.now 35 | parent.destroy 36 | child.update_attributes :deleted_at => nil 37 | 38 | assert model.count.zero? 39 | end 40 | end 41 | 42 | def test_node_excluded_by_default_scope_should_be_rootified 43 | AncestryTestDatabase.with_model( 44 | :width => 1, :depth => 2, :extra_columns => {:deleted_at => :datetime}, 45 | :default_scope_params => {:deleted_at => nil}, 46 | :orphan_strategy => :rootify 47 | ) do |model, roots| 48 | parent = model.roots.first 49 | child = parent.children.first 50 | 51 | child.update_attributes :deleted_at => Time.now 52 | parent.destroy 53 | child.update_attributes :deleted_at => nil 54 | 55 | assert child.reload.is_root? 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/concerns/depth_constraints_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../environment' 2 | 3 | class DepthConstraintsTest < ActiveSupport::TestCase 4 | def test_descendants_with_depth_constraints 5 | AncestryTestDatabase.with_model :depth => 4, :width => 4, :cache_depth => true do |model, roots| 6 | assert_equal 4, model.roots.first.descendants(:before_depth => 2).count 7 | assert_equal 20, model.roots.first.descendants(:to_depth => 2).count 8 | assert_equal 16, model.roots.first.descendants(:at_depth => 2).count 9 | assert_equal 80, model.roots.first.descendants(:from_depth => 2).count 10 | assert_equal 64, model.roots.first.descendants(:after_depth => 2).count 11 | end 12 | end 13 | 14 | def test_subtree_with_depth_constraints 15 | AncestryTestDatabase.with_model :depth => 4, :width => 4, :cache_depth => true do |model, roots| 16 | assert_equal 5, model.roots.first.subtree(:before_depth => 2).count 17 | assert_equal 21, model.roots.first.subtree(:to_depth => 2).count 18 | assert_equal 16, model.roots.first.subtree(:at_depth => 2).count 19 | assert_equal 80, model.roots.first.subtree(:from_depth => 2).count 20 | assert_equal 64, model.roots.first.subtree(:after_depth => 2).count 21 | end 22 | end 23 | 24 | 25 | def test_ancestors_with_depth_constraints 26 | AncestryTestDatabase.with_model :cache_depth => true do |model| 27 | node1 = model.create! 28 | node2 = node1.children.create! 29 | node3 = node2.children.create! 30 | node4 = node3.children.create! 31 | node5 = node4.children.create! 32 | leaf = node5.children.create! 33 | 34 | assert_equal [node1, node2, node3], leaf.ancestors(:before_depth => -2) 35 | assert_equal [node1, node2, node3, node4], leaf.ancestors(:to_depth => -2) 36 | assert_equal [node4], leaf.ancestors(:at_depth => -2) 37 | assert_equal [node4, node5], leaf.ancestors(:from_depth => -2) 38 | assert_equal [node5], leaf.ancestors(:after_depth => -2) 39 | end 40 | end 41 | 42 | def test_path_with_depth_constraints 43 | AncestryTestDatabase.with_model :cache_depth => true do |model| 44 | node1 = model.create! 45 | node2 = node1.children.create! 46 | node3 = node2.children.create! 47 | node4 = node3.children.create! 48 | node5 = node4.children.create! 49 | leaf = node5.children.create! 50 | 51 | assert_equal [node1, node2, node3], leaf.path(:before_depth => -2) 52 | assert_equal [node1, node2, node3, node4], leaf.path(:to_depth => -2) 53 | assert_equal [node4], leaf.path(:at_depth => -2) 54 | assert_equal [node4, node5, leaf], leaf.path(:from_depth => -2) 55 | assert_equal [node5, leaf], leaf.path(:after_depth => -2) 56 | end 57 | end 58 | end -------------------------------------------------------------------------------- /test/concerns/scopes_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../environment' 2 | 3 | class ScopesTest < ActiveSupport::TestCase 4 | def test_scopes 5 | AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, roots| 6 | # Roots assertion 7 | assert_equal roots.map(&:first), model.roots.to_a 8 | 9 | model.all.each do |test_node| 10 | # Assertions for ancestors_of named scope 11 | assert_equal test_node.ancestors.to_a, model.ancestors_of(test_node).to_a 12 | assert_equal test_node.ancestors.to_a, model.ancestors_of(test_node.id).to_a 13 | # Assertions for children_of named scope 14 | assert_equal test_node.children.to_a, model.children_of(test_node).to_a 15 | assert_equal test_node.children.to_a, model.children_of(test_node.id).to_a 16 | # Assertions for descendants_of named scope 17 | assert_equal test_node.descendants.to_a, model.descendants_of(test_node).to_a 18 | assert_equal test_node.descendants.to_a, model.descendants_of(test_node.id).to_a 19 | # Assertions for subtree_of named scope 20 | assert_equal test_node.subtree.to_a, model.subtree_of(test_node).to_a 21 | assert_equal test_node.subtree.to_a, model.subtree_of(test_node.id).to_a 22 | # Assertions for siblings_of named scope 23 | assert_equal test_node.siblings.to_a, model.siblings_of(test_node).to_a 24 | assert_equal test_node.siblings.to_a, model.siblings_of(test_node.id).to_a 25 | # Assertions for path_of named scope 26 | assert_equal test_node.path.to_a, model.path_of(test_node).to_a 27 | assert_equal test_node.path.to_a, model.path_of(test_node.id).to_a 28 | end 29 | end 30 | end 31 | 32 | def test_node_creation_through_scope 33 | AncestryTestDatabase.with_model do |model| 34 | node = model.create! 35 | child = node.children.create 36 | assert_equal node, child.parent 37 | 38 | other_child = child.siblings.create! 39 | assert_equal node, other_child.parent 40 | 41 | grandchild = model.children_of(child).new 42 | grandchild.save 43 | assert_equal child, grandchild.parent 44 | 45 | other_grandchild = model.siblings_of(grandchild).new 46 | other_grandchild.save! 47 | assert_equal child, other_grandchild.parent 48 | end 49 | end 50 | 51 | def test_scoping_in_callbacks 52 | AncestryTestDatabase.with_model do |model| 53 | record = model.create 54 | 55 | model.instance_eval do 56 | after_create :after_create_callback 57 | end 58 | 59 | model.class_eval do 60 | define_method :after_create_callback do 61 | # We don't want to be in the #children scope here when creating the child 62 | self.parent 63 | self.parent_id = record.id if record 64 | self.root 65 | end 66 | end 67 | 68 | parent = model.create 69 | assert parent.children.create 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/concerns/depth_caching_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../environment' 2 | 3 | class DepthCachingTest < ActiveSupport::TestCase 4 | def test_depth_caching 5 | AncestryTestDatabase.with_model :depth => 3, :width => 3, :cache_depth => true, :depth_cache_column => :depth_cache do |model, roots| 6 | roots.each do |lvl0_node, lvl0_children| 7 | assert_equal 0, lvl0_node.depth_cache 8 | lvl0_children.each do |lvl1_node, lvl1_children| 9 | assert_equal 1, lvl1_node.depth_cache 10 | lvl1_children.each do |lvl2_node, lvl2_children| 11 | assert_equal 2, lvl2_node.depth_cache 12 | end 13 | end 14 | end 15 | end 16 | end 17 | 18 | def test_depth_caching_after_subtree_movement 19 | AncestryTestDatabase.with_model :depth => 6, :width => 1, :cache_depth => true, :depth_cache_column => :depth_cache do |model, roots| 20 | node = model.at_depth(3).first 21 | node.update_attributes(:parent => model.roots.first) 22 | assert_equal(1, node.depth_cache) 23 | node.descendants.each do |descendant| 24 | assert_equal(descendant.depth, descendant.depth_cache) 25 | end 26 | end 27 | end 28 | 29 | def test_depth_scopes 30 | AncestryTestDatabase.with_model :depth => 4, :width => 2, :cache_depth => true do |model, roots| 31 | model.before_depth(2).all? { |node| assert node.depth < 2 } 32 | model.to_depth(2).all? { |node| assert node.depth <= 2 } 33 | model.at_depth(2).all? { |node| assert node.depth == 2 } 34 | model.from_depth(2).all? { |node| assert node.depth >= 2 } 35 | model.after_depth(2).all? { |node| assert node.depth > 2 } 36 | end 37 | end 38 | 39 | def test_depth_scopes_unavailable 40 | AncestryTestDatabase.with_model do |model| 41 | assert_raise Ancestry::AncestryException do 42 | model.before_depth(1) 43 | end 44 | assert_raise Ancestry::AncestryException do 45 | model.to_depth(1) 46 | end 47 | assert_raise Ancestry::AncestryException do 48 | model.at_depth(1) 49 | end 50 | assert_raise Ancestry::AncestryException do 51 | model.from_depth(1) 52 | end 53 | assert_raise Ancestry::AncestryException do 54 | model.after_depth(1) 55 | end 56 | end 57 | end 58 | 59 | def test_rebuild_depth_cache 60 | AncestryTestDatabase.with_model :depth => 3, :width => 3, :cache_depth => true, :depth_cache_column => :depth_cache do |model, roots| 61 | model.connection.execute("update test_nodes set depth_cache = null;") 62 | 63 | # Assert cache was emptied correctly 64 | model.all.each do |test_node| 65 | assert_nil test_node.depth_cache 66 | end 67 | 68 | # Rebuild cache 69 | model.rebuild_depth_cache! 70 | 71 | # Assert cache was rebuild correctly 72 | model.all.each do |test_node| 73 | assert_equal test_node.depth, test_node.depth_cache 74 | end 75 | end 76 | end 77 | 78 | def test_exception_when_rebuilding_depth_cache_for_model_without_depth_caching 79 | AncestryTestDatabase.with_model do |model| 80 | assert_raise Ancestry::AncestryException do 81 | model.rebuild_depth_cache! 82 | end 83 | end 84 | end 85 | 86 | def test_exception_on_unknown_depth_column 87 | AncestryTestDatabase.with_model :cache_depth => true do |model| 88 | assert_raise Ancestry::AncestryException do 89 | model.create!.subtree(:this_is_not_a_valid_depth_option => 42) 90 | end 91 | end 92 | end 93 | end -------------------------------------------------------------------------------- /test/concerns/orphan_strategies_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../environment' 2 | 3 | class OphanStrategiesTest < ActiveSupport::TestCase 4 | def test_default_orphan_strategy 5 | AncestryTestDatabase.with_model do |model| 6 | assert_equal :destroy, model.orphan_strategy 7 | end 8 | end 9 | 10 | def test_non_default_orphan_strategy 11 | AncestryTestDatabase.with_model :orphan_strategy => :rootify do |model| 12 | assert_equal :rootify, model.orphan_strategy 13 | end 14 | end 15 | 16 | def test_setting_orphan_strategy 17 | AncestryTestDatabase.with_model do |model| 18 | model.orphan_strategy = :rootify 19 | assert_equal :rootify, model.orphan_strategy 20 | model.orphan_strategy = :destroy 21 | assert_equal :destroy, model.orphan_strategy 22 | end 23 | end 24 | 25 | def test_setting_invalid_orphan_strategy 26 | AncestryTestDatabase.with_model do |model| 27 | assert_raise Ancestry::AncestryException do 28 | model.orphan_strategy = :non_existent_orphan_strategy 29 | end 30 | end 31 | end 32 | 33 | def test_orphan_rootify_strategy 34 | AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, roots| 35 | model.orphan_strategy = :rootify 36 | root = roots.first.first 37 | children = root.children.to_a 38 | root.destroy 39 | children.each do |child| 40 | child.reload 41 | assert child.is_root? 42 | assert_equal 3, child.children.size 43 | end 44 | end 45 | end 46 | 47 | def test_orphan_destroy_strategy 48 | AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, roots| 49 | model.orphan_strategy = :destroy 50 | root = roots.first.first 51 | assert_difference 'model.count', -root.subtree.size do 52 | root.destroy 53 | end 54 | node = model.roots.first.children.first 55 | assert_difference 'model.count', -node.subtree.size do 56 | node.destroy 57 | end 58 | end 59 | end 60 | 61 | def test_orphan_restrict_strategy 62 | AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, roots| 63 | model.orphan_strategy = :restrict 64 | root = roots.first.first 65 | assert_raise Ancestry::AncestryException do 66 | root.destroy 67 | end 68 | assert_nothing_raised do 69 | root.children.first.children.first.destroy 70 | end 71 | end 72 | end 73 | 74 | def test_orphan_adopt_strategy 75 | AncestryTestDatabase.with_model do |model| 76 | model.orphan_strategy = :adopt # set the orphan strategy as paerntify 77 | n1 = model.create! #create a root node 78 | n2 = model.create!(:parent => n1) #create child with parent=root 79 | n3 = model.create!(:parent => n2) #create child with parent=n2, depth = 2 80 | n4 = model.create!(:parent => n2) #create child with parent=n2, depth = 2 81 | n5 = model.create!(:parent => n4) #create child with parent=n4, depth = 3 82 | n2.destroy # delete a node with desecendants 83 | assert_equal(model.find(n3.id).parent,n1, "orphan's not parentified" ) 84 | assert_equal(model.find(n5.id).ancestor_ids,[n1.id,n4.id], "ancestry integrity not maintained") 85 | n1.destroy # delete a root node with desecendants 86 | assert_nil(model.find(n3.id).ancestry," new root node has no empty ancestry string") 87 | assert_equal(model.find(n3.id).valid?,true," new root node is not valid") 88 | assert_nil(model.find(n3.id).parent_id," Children of the deleted root not rootfied") 89 | assert_equal(model.find(n5.id).ancestor_ids,[n4.id],"ancestry integrity not maintained") 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/concerns/touching_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../environment' 2 | 3 | class TouchingTest < ActiveSupport::TestCase 4 | def test_touch_option_disabled 5 | AncestryTestDatabase.with_model( 6 | :extra_columns => {:name => :string, :updated_at => :datetime}, 7 | :touch => false 8 | ) do |model| 9 | 10 | yesterday = Time.now - 1.day 11 | parent = model.create!(:updated_at => yesterday) 12 | child = model.create!(:updated_at => yesterday, :parent => parent) 13 | 14 | child.update_attributes(:name => "Changed") 15 | assert_equal yesterday.utc.change(:usec => 0), parent.updated_at.utc.change(:usec => 0) 16 | end 17 | end 18 | 19 | def test_touch_option_enabled_propagates_with_modification 20 | AncestryTestDatabase.with_model( 21 | :extra_columns => {:updated_at => :datetime}, 22 | :touch => true 23 | ) do |model| 24 | 25 | way_back = Time.new(1984) 26 | recently = Time.now - 1.minute 27 | 28 | parent_1 = model.create!(:updated_at => way_back) 29 | parent_2 = model.create!(:updated_at => way_back) 30 | child_1_1 = model.create!(:updated_at => way_back, :parent => parent_1) 31 | child_1_2 = model.create!(:updated_at => way_back, :parent => parent_1) 32 | grandchild_1_1_1 = model.create!(:updated_at => way_back, :parent => child_1_1) 33 | grandchild_1_1_2 = model.create!(:updated_at => way_back, :parent => child_1_1) 34 | 35 | grandchild_1_1_1.parent = parent_2 36 | grandchild_1_1_1.save! 37 | 38 | assert grandchild_1_1_1.reload.updated_at > recently, "record was not touched" 39 | assert child_1_1.reload.updated_at > recently, "old parent was not touched" 40 | assert parent_1.reload.updated_at > recently, "old grandparent was not touched" 41 | assert parent_2.reload.updated_at > recently, "new parent was not touched" 42 | 43 | assert_equal way_back, grandchild_1_1_2.reload.updated_at, "old sibling was touched" 44 | assert_equal way_back, child_1_2.reload.updated_at, "unrelated record was touched" 45 | end 46 | end 47 | 48 | def test_touch_option_enabled_doesnt_propagate_without_modification 49 | AncestryTestDatabase.with_model( 50 | :extra_columns => {:updated_at => :datetime}, 51 | :touch => true 52 | ) do |model| 53 | 54 | way_back = Time.new(1984) 55 | 56 | parent = model.create! 57 | child = model.create!(:parent => parent) 58 | grandchild = model.create!(:parent => child) 59 | model.update_all(updated_at: way_back) 60 | grandchild.save 61 | 62 | assert_equal way_back, grandchild.reload.updated_at, "main record updated_at timestamp was touched" 63 | assert_equal way_back, child.reload.updated_at, "parent record was touched" 64 | assert_equal way_back, parent.reload.updated_at, "grandparent record was touched" 65 | end 66 | end 67 | 68 | def test_touch_option_with_scope 69 | AncestryTestDatabase.with_model( 70 | :extra_columns => {:updated_at => :datetime}, 71 | :touch => true 72 | ) do |model| 73 | 74 | way_back = Time.new(1984) 75 | recently = Time.now - 1.minute 76 | 77 | parent_1 = model.create!(:updated_at => way_back) 78 | child_1_1 = model.create!(:updated_at => way_back, :parent => parent_1) 79 | child_1_2 = model.create!(:updated_at => way_back, :parent => parent_1) 80 | grandchild_1_1_1 = model.create!(:updated_at => way_back, :parent => child_1_1) 81 | 82 | grandchild_1_1_1.children.create! 83 | 84 | assert_equal way_back, child_1_2.reload.updated_at, "unrelated record was touched" 85 | 86 | assert grandchild_1_1_1.reload.updated_at > recently, "parent was not touched" 87 | assert child_1_1.reload.updated_at > recently, "grandparent was not touched" 88 | assert parent_1.reload.updated_at > recently, "great grandparent was not touched" 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /test/concerns/integrity_checking_and_restoration_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../environment' 2 | 3 | class IntegrityCheckingAndRestaurationTest < ActiveSupport::TestCase 4 | def test_integrity_checking 5 | AncestryTestDatabase.with_model :width => 3, :depth => 3 do |model, roots| 6 | # Check that there are no errors on a valid tree 7 | assert_nothing_raised do 8 | model.check_ancestry_integrity! 9 | end 10 | assert_equal 0, model.check_ancestry_integrity!(:report => :list).size 11 | end 12 | 13 | AncestryTestDatabase.with_model :width => 3, :depth => 3 do |model, roots| 14 | # Check detection of invalid format for ancestry column 15 | roots.first.first.update_attribute model.ancestry_column, 'invalid_ancestry' 16 | assert_raise Ancestry::AncestryIntegrityException do 17 | model.check_ancestry_integrity! 18 | end 19 | assert_equal 1, model.check_ancestry_integrity!(:report => :list).size 20 | end 21 | 22 | AncestryTestDatabase.with_model :width => 3, :depth => 3 do |model, roots| 23 | # Check detection of non-existent ancestor 24 | roots.first.first.update_attribute model.ancestry_column, 35 25 | assert_raise Ancestry::AncestryIntegrityException do 26 | model.check_ancestry_integrity! 27 | end 28 | assert_equal 1, model.check_ancestry_integrity!(:report => :list).size 29 | end 30 | 31 | AncestryTestDatabase.with_model :width => 3, :depth => 3 do |model, roots| 32 | # Check detection of cyclic ancestry 33 | node = roots.first.first 34 | node.update_attribute model.ancestry_column, node.id 35 | assert_raise Ancestry::AncestryIntegrityException do 36 | model.check_ancestry_integrity! 37 | end 38 | assert_equal 1, model.check_ancestry_integrity!(:report => :list).size 39 | end 40 | 41 | AncestryTestDatabase.with_model do |model| 42 | # Check detection of conflicting parent id 43 | model.destroy_all 44 | model.create!(model.ancestry_column => model.create!(model.ancestry_column => model.create!(model.ancestry_column => nil).id).id) 45 | assert_raise Ancestry::AncestryIntegrityException do 46 | model.check_ancestry_integrity! 47 | end 48 | assert_equal 1, model.check_ancestry_integrity!(:report => :list).size 49 | end 50 | end 51 | 52 | def assert_integrity_restoration model 53 | assert_raise Ancestry::AncestryIntegrityException do 54 | model.check_ancestry_integrity! 55 | end 56 | model.restore_ancestry_integrity! 57 | assert_nothing_raised do 58 | model.check_ancestry_integrity! 59 | end 60 | assert model.all.any? {|node| node.ancestry.present? }, "Expected some nodes not to be roots" 61 | assert_equal model.count, model.roots.collect {|node| node.descendants.count + 1 }.sum 62 | end 63 | 64 | def test_integrity_restoration 65 | width, depth = 3, 3 66 | # Check that integrity is restored for invalid format for ancestry column 67 | AncestryTestDatabase.with_model :width => width, :depth => depth do |model, roots| 68 | roots.first.first.update_attribute model.ancestry_column, 'invalid_ancestry' 69 | assert_integrity_restoration model 70 | end 71 | 72 | # Check that integrity is restored for non-existent ancestor 73 | AncestryTestDatabase.with_model :width => width, :depth => depth do |model, roots| 74 | roots.first.first.update_attribute model.ancestry_column, 35 75 | assert_integrity_restoration model 76 | end 77 | 78 | # Check that integrity is restored for cyclic ancestry 79 | AncestryTestDatabase.with_model :width => width, :depth => depth do |model, roots| 80 | node = roots.first.first 81 | node.update_attribute model.ancestry_column, node.id 82 | assert_integrity_restoration model 83 | end 84 | 85 | # Check that integrity is restored for conflicting parent id 86 | AncestryTestDatabase.with_model do |model| 87 | model.destroy_all 88 | model.create!(model.ancestry_column => model.create!(model.ancestry_column => model.create!(model.ancestry_column => nil).id).id) 89 | assert_integrity_restoration model 90 | end 91 | end 92 | end -------------------------------------------------------------------------------- /test/environment.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | 4 | require 'simplecov' 5 | require 'coveralls' 6 | SimpleCov.formatter = Coveralls::SimpleCov::Formatter 7 | SimpleCov.start do 8 | add_filter '/test/' 9 | add_filter '/vendor/' 10 | end 11 | 12 | require 'active_support' 13 | require 'active_support/test_case' 14 | ActiveSupport.test_order = :random if ActiveSupport.respond_to?(:test_order=) 15 | 16 | require 'active_record' 17 | require 'logger' 18 | 19 | # Make absolutely sure we are testing local ancestry 20 | require File.expand_path('../../lib/ancestry', __FILE__) 21 | 22 | class AncestryTestDatabase 23 | def self.setup 24 | # Silence I18n and Activerecord logging 25 | I18n.enforce_available_locales = false if I18n.respond_to? :enforce_available_locales= 26 | ActiveRecord::Base.logger = Logger.new(STDERR) 27 | ActiveRecord::Base.logger.level = Logger::Severity::UNKNOWN 28 | 29 | # Assume Travis CI database config if no custom one exists 30 | filename = if File.exist?(File.expand_path('../database.yml', __FILE__)) 31 | File.expand_path('../database.yml', __FILE__) 32 | else 33 | File.expand_path('../database.ci.yml', __FILE__) 34 | end 35 | 36 | # Setup database connection 37 | db_type = 38 | if ENV["BUNDLE_GEMFILE"] && ENV["BUNDLE_GEMFILE"] != File.expand_path("../../Gemfile", __FILE__) 39 | File.basename(ENV["BUNDLE_GEMFILE"]).split("_").first 40 | else 41 | "sqlite3" 42 | end 43 | config = YAML.load_file(filename)[db_type] 44 | ActiveRecord::Base.establish_connection config 45 | begin 46 | ActiveRecord::Base.connection 47 | rescue => err 48 | if ENV["CI"] 49 | raise 50 | else 51 | puts "\nSkipping tests for '#{db_type}'" 52 | puts " #{err}\n\n" 53 | exit 0 54 | end 55 | end 56 | end 57 | 58 | def self.with_model options = {} 59 | depth = options.delete(:depth) || 0 60 | width = options.delete(:width) || 0 61 | extra_columns = options.delete(:extra_columns) 62 | default_scope_params = options.delete(:default_scope_params) 63 | 64 | ActiveRecord::Base.connection.create_table 'test_nodes' do |table| 65 | table.string options[:ancestry_column] || :ancestry 66 | table.integer options[:depth_cache_column] || :ancestry_depth if options[:cache_depth] 67 | extra_columns.each do |name, type| 68 | table.send type, name 69 | end unless extra_columns.nil? 70 | end 71 | 72 | testmethod = caller[0][/`.*'/][1..-2] 73 | model_name = testmethod.camelize + "TestNode" 74 | 75 | begin 76 | model = Class.new(ActiveRecord::Base) 77 | const_set model_name, model 78 | 79 | model.table_name = 'test_nodes' 80 | 81 | if default_scope_params.present? 82 | 83 | # Rails < 3.1 doesn't support lambda default_scopes (only hashes) 84 | # But Rails >= 4 logs deprecation warnings for hash default_scopes 85 | if ActiveRecord::VERSION::STRING < "3.1" 86 | model.send :default_scope, { :conditions => default_scope_params } 87 | else 88 | model.send :default_scope, lambda { model.where(default_scope_params) } 89 | end 90 | end 91 | 92 | model.has_ancestry options unless options.delete(:skip_ancestry) 93 | 94 | if depth > 0 95 | yield model, create_test_nodes(model, depth, width) 96 | else 97 | yield model 98 | end 99 | ensure 100 | model.reset_column_information 101 | ActiveRecord::Base.connection.drop_table 'test_nodes' 102 | remove_const model_name 103 | end 104 | end 105 | 106 | def self.create_test_nodes model, depth, width, parent = nil 107 | unless depth == 0 108 | Array.new width do 109 | node = model.create!(:parent => parent) 110 | [node, create_test_nodes(model, depth - 1, width, node)] 111 | end 112 | else; []; end 113 | end 114 | end 115 | 116 | AncestryTestDatabase.setup 117 | 118 | puts "\nLoaded Ancestry test suite environment:" 119 | puts " Ruby: #{RUBY_VERSION}" 120 | puts " ActiveRecord: #{ActiveRecord::VERSION::STRING}" 121 | puts " Database: #{ActiveRecord::Base.connection.adapter_name}\n\n" 122 | 123 | require 'minitest/autorun' if ActiveSupport::VERSION::STRING > "4" 124 | -------------------------------------------------------------------------------- /test/concerns/has_ancestry_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../environment' 2 | 3 | class HasAncestryTreeTest < ActiveSupport::TestCase 4 | def test_default_ancestry_column 5 | AncestryTestDatabase.with_model do |model| 6 | assert_equal :ancestry, model.ancestry_column 7 | end 8 | end 9 | 10 | def test_non_default_ancestry_column 11 | AncestryTestDatabase.with_model :ancestry_column => :alternative_ancestry do |model| 12 | assert_equal :alternative_ancestry, model.ancestry_column 13 | end 14 | end 15 | 16 | def test_setting_ancestry_column 17 | AncestryTestDatabase.with_model do |model| 18 | model.ancestry_column = :ancestors 19 | assert_equal :ancestors, model.ancestry_column 20 | model.ancestry_column = :ancestry 21 | assert_equal :ancestry, model.ancestry_column 22 | end 23 | end 24 | 25 | def test_invalid_has_ancestry_options 26 | assert_raise Ancestry::AncestryException do 27 | Class.new(ActiveRecord::Base).has_ancestry :this_option_doesnt_exist => 42 28 | end 29 | assert_raise Ancestry::AncestryException do 30 | Class.new(ActiveRecord::Base).has_ancestry :not_a_hash 31 | end 32 | end 33 | 34 | def test_descendants_move_with_node 35 | AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, roots| 36 | root1, root2, root3 = roots.map(&:first) 37 | assert_no_difference 'root1.descendants.size' do 38 | assert_difference 'root2.descendants.size', root1.subtree.size do 39 | root1.parent = root2 40 | root1.save! 41 | end 42 | end 43 | assert_no_difference 'root2.descendants.size' do 44 | assert_difference 'root3.descendants.size', root2.subtree.size do 45 | root2.parent = root3 46 | root2.save! 47 | end 48 | end 49 | assert_no_difference 'root1.descendants.size' do 50 | assert_difference 'root2.descendants.size', -root1.subtree.size do 51 | assert_difference 'root3.descendants.size', -root1.subtree.size do 52 | root1.parent = nil 53 | root1.save! 54 | end 55 | end 56 | end 57 | end 58 | end 59 | 60 | def test_set_parent_with_non_default_ancestry_column 61 | AncestryTestDatabase.with_model :depth => 3, :width => 3, :ancestry_column => :alternative_ancestry do |model, roots| 62 | root1, root2, _root3 = roots.map(&:first) 63 | assert_no_difference 'root1.descendants.size' do 64 | assert_difference 'root2.descendants.size', root1.subtree.size do 65 | root1.parent = root2 66 | root1.save! 67 | end 68 | end 69 | end 70 | end 71 | 72 | def test_set_parent_id 73 | AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, roots| 74 | root1, root2, _root3 = roots.map(&:first) 75 | assert_no_difference 'root1.descendants.size' do 76 | assert_difference 'root2.descendants.size', root1.subtree.size do 77 | root1.parent_id = root2.id 78 | root1.save! 79 | end 80 | end 81 | end 82 | end 83 | 84 | def test_set_parent_id_with_non_default_ancestry_column 85 | AncestryTestDatabase.with_model :depth => 3, :width => 3, :ancestry_column => :alternative_ancestry do |model, roots| 86 | root1, root2, _root3 = roots.map(&:first) 87 | assert_no_difference 'root1.descendants.size' do 88 | assert_difference 'root2.descendants.size', root1.subtree.size do 89 | root1.parent_id = root2.id 90 | root1.save! 91 | end 92 | end 93 | end 94 | end 95 | 96 | def test_setup_test_nodes 97 | AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, roots| 98 | assert_equal Array, roots.class 99 | assert_equal 3, roots.length 100 | roots.each do |node1, children1| 101 | assert_equal model, node1.class 102 | assert_equal Array, children1.class 103 | assert_equal 3, children1.length 104 | children1.each do |node2, children2| 105 | assert_equal model, node2.class 106 | assert_equal Array, children2.class 107 | assert_equal 3, children2.length 108 | children2.each do |node3, children3| 109 | assert_equal model, node3.class 110 | assert_equal Array, children3.class 111 | assert_equal 0, children3.length 112 | end 113 | end 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/ancestry/has_ancestry.rb: -------------------------------------------------------------------------------- 1 | module Ancestry 2 | module HasAncestry 3 | def has_ancestry options = {} 4 | # Check options 5 | raise Ancestry::AncestryException.new("Options for has_ancestry must be in a hash.") unless options.is_a? Hash 6 | options.each do |key, value| 7 | unless [:ancestry_column, :orphan_strategy, :cache_depth, :depth_cache_column, :touch].include? key 8 | raise Ancestry::AncestryException.new("Unknown option for has_ancestry: #{key.inspect} => #{value.inspect}.") 9 | end 10 | end 11 | 12 | 13 | # Create ancestry column accessor and set to option or default 14 | cattr_accessor :ancestry_column 15 | self.ancestry_column = options[:ancestry_column] || :ancestry 16 | 17 | # Save self as base class (for STI) 18 | cattr_accessor :ancestry_base_class 19 | self.ancestry_base_class = self 20 | 21 | # Touch ancestors after updating 22 | cattr_accessor :touch_ancestors 23 | self.touch_ancestors = options[:touch] || false 24 | 25 | # Include instance methods 26 | include Ancestry::InstanceMethods 27 | 28 | # Include dynamic class methods 29 | extend Ancestry::ClassMethods 30 | 31 | extend Ancestry::MaterializedPath 32 | 33 | # Create orphan strategy accessor and set to option or default (writer comes from DynamicClassMethods) 34 | cattr_reader :orphan_strategy 35 | self.orphan_strategy = options[:orphan_strategy] || :destroy 36 | 37 | # Validate that the ancestor ids don't include own id 38 | validate :ancestry_exclude_self 39 | 40 | # Named scopes 41 | scope :roots, lambda { where(root_conditions) } 42 | scope :ancestors_of, lambda { |object| where(ancestor_conditions(object)) } 43 | scope :path_of, lambda { |object| where(path_conditions(object)) } 44 | scope :children_of, lambda { |object| where(child_conditions(object)) } 45 | scope :descendants_of, lambda { |object| where(descendant_conditions(object)) } 46 | scope :subtree_of, lambda { |object| where(subtree_conditions(object)) } 47 | scope :siblings_of, lambda { |object| where(sibling_conditions(object)) } 48 | scope :ordered_by_ancestry, Proc.new { |order| 49 | if %w(mysql mysql2 sqlite sqlite3 postgresql).include?(connection.adapter_name.downcase) && ActiveRecord::VERSION::MAJOR >= 5 50 | reorder("coalesce(#{connection.quote_table_name(table_name)}.#{connection.quote_column_name(ancestry_column)}, '')", order) 51 | else 52 | reorder("(CASE WHEN #{connection.quote_table_name(table_name)}.#{connection.quote_column_name(ancestry_column)} IS NULL THEN 0 ELSE 1 END), #{connection.quote_table_name(table_name)}.#{connection.quote_column_name(ancestry_column)}", order) 53 | end 54 | } 55 | scope :ordered_by_ancestry_and, Proc.new { |order| ordered_by_ancestry_and(order) } 56 | scope :path_of, lambda { |object| to_node(object).path } 57 | 58 | # Update descendants with new ancestry before save 59 | before_save :update_descendants_with_new_ancestry 60 | 61 | # Apply orphan strategy before destroy 62 | before_destroy :apply_orphan_strategy 63 | 64 | # Create ancestry column accessor and set to option or default 65 | if options[:cache_depth] 66 | # Create accessor for column name and set to option or default 67 | self.cattr_accessor :depth_cache_column 68 | self.depth_cache_column = options[:depth_cache_column] || :ancestry_depth 69 | 70 | # Cache depth in depth cache column before save 71 | before_validation :cache_depth 72 | before_save :cache_depth 73 | 74 | # Validate depth column 75 | validates_numericality_of depth_cache_column, :greater_than_or_equal_to => 0, :only_integer => true, :allow_nil => false 76 | end 77 | 78 | # Create named scopes for depth 79 | {:before_depth => '<', :to_depth => '<=', :at_depth => '=', :from_depth => '>=', :after_depth => '>'}.each do |scope_name, operator| 80 | scope scope_name, lambda { |depth| 81 | raise Ancestry::AncestryException.new("Named scope '#{scope_name}' is only available when depth caching is enabled.") unless options[:cache_depth] 82 | where("#{depth_cache_column} #{operator} ?", depth) 83 | } 84 | end 85 | 86 | after_touch :touch_ancestors_callback 87 | after_destroy :touch_ancestors_callback 88 | 89 | if ActiveRecord::VERSION::STRING >= '5.1.0' 90 | after_save :touch_ancestors_callback, if: :saved_changes? 91 | else 92 | after_save :touch_ancestors_callback, if: :changed? 93 | end 94 | end 95 | 96 | def acts_as_tree(*args) 97 | return super if defined?(super) 98 | has_ancestry(*args) 99 | end 100 | end 101 | end 102 | 103 | ActiveSupport.on_load :active_record do 104 | send :extend, Ancestry::HasAncestry 105 | end 106 | -------------------------------------------------------------------------------- /test/concerns/tree_navigration_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../environment' 2 | 3 | class TreeNavigationTest < ActiveSupport::TestCase 4 | def test_tree_navigation 5 | AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, roots| 6 | roots.each do |lvl0_node, lvl0_children| 7 | # Ancestors assertions 8 | assert_equal [], lvl0_node.ancestor_ids 9 | assert_equal [], lvl0_node.ancestors 10 | assert_equal [lvl0_node.id], lvl0_node.path_ids 11 | assert_equal [lvl0_node], lvl0_node.path 12 | assert_equal 0, lvl0_node.depth 13 | # Parent assertions 14 | assert_nil lvl0_node.parent_id 15 | assert_nil lvl0_node.parent 16 | refute lvl0_node.parent_id? 17 | # Root assertions 18 | assert_equal lvl0_node.id, lvl0_node.root_id 19 | assert_equal lvl0_node, lvl0_node.root 20 | assert lvl0_node.is_root? 21 | # Children assertions 22 | assert_equal lvl0_children.map(&:first).map(&:id), lvl0_node.child_ids 23 | assert_equal lvl0_children.map(&:first), lvl0_node.children 24 | assert lvl0_node.has_children? 25 | assert !lvl0_node.is_childless? 26 | # Siblings assertions 27 | assert_equal roots.map(&:first).map(&:id), lvl0_node.sibling_ids 28 | assert_equal roots.map(&:first), lvl0_node.siblings 29 | assert lvl0_node.has_siblings? 30 | assert !lvl0_node.is_only_child? 31 | # Descendants assertions 32 | descendants = model.all.find_all do |node| 33 | node.ancestor_ids.include? lvl0_node.id 34 | end 35 | assert_equal descendants.map(&:id), lvl0_node.descendant_ids 36 | assert_equal descendants, lvl0_node.descendants 37 | assert_equal [lvl0_node] + descendants, lvl0_node.subtree 38 | 39 | lvl0_children.each do |lvl1_node, lvl1_children| 40 | # Ancestors assertions 41 | assert_equal [lvl0_node.id], lvl1_node.ancestor_ids 42 | assert_equal [lvl0_node], lvl1_node.ancestors 43 | assert_equal [lvl0_node.id, lvl1_node.id], lvl1_node.path_ids 44 | assert_equal [lvl0_node, lvl1_node], lvl1_node.path 45 | assert_equal 1, lvl1_node.depth 46 | # Parent assertions 47 | assert_equal lvl0_node.id, lvl1_node.parent_id 48 | assert_equal lvl0_node, lvl1_node.parent 49 | assert lvl1_node.parent_id? 50 | # Root assertions 51 | assert_equal lvl0_node.id, lvl1_node.root_id 52 | assert_equal lvl0_node, lvl1_node.root 53 | assert !lvl1_node.is_root? 54 | # Children assertions 55 | assert_equal lvl1_children.map(&:first).map(&:id), lvl1_node.child_ids 56 | assert_equal lvl1_children.map(&:first), lvl1_node.children 57 | assert lvl1_node.has_children? 58 | assert !lvl1_node.is_childless? 59 | # Siblings assertions 60 | assert_equal lvl0_children.map(&:first).map(&:id), lvl1_node.sibling_ids 61 | assert_equal lvl0_children.map(&:first), lvl1_node.siblings 62 | assert lvl1_node.has_siblings? 63 | assert !lvl1_node.is_only_child? 64 | # Descendants assertions 65 | descendants = model.all.find_all do |node| 66 | node.ancestor_ids.include? lvl1_node.id 67 | end 68 | assert_equal descendants.map(&:id), lvl1_node.descendant_ids 69 | assert_equal descendants, lvl1_node.descendants 70 | assert_equal [lvl1_node] + descendants, lvl1_node.subtree 71 | 72 | lvl1_children.each do |lvl2_node, lvl2_children| 73 | # Ancestors assertions 74 | assert_equal [lvl0_node.id, lvl1_node.id], lvl2_node.ancestor_ids 75 | assert_equal [lvl0_node, lvl1_node], lvl2_node.ancestors 76 | assert_equal [lvl0_node.id, lvl1_node.id, lvl2_node.id], lvl2_node.path_ids 77 | assert_equal [lvl0_node, lvl1_node, lvl2_node], lvl2_node.path 78 | assert_equal 2, lvl2_node.depth 79 | # Parent assertions 80 | assert_equal lvl1_node.id, lvl2_node.parent_id 81 | assert_equal lvl1_node, lvl2_node.parent 82 | assert lvl2_node.parent_id? 83 | # Root assertions 84 | assert_equal lvl0_node.id, lvl2_node.root_id 85 | assert_equal lvl0_node, lvl2_node.root 86 | assert !lvl2_node.is_root? 87 | # Children assertions 88 | assert_equal [], lvl2_node.child_ids 89 | assert_equal [], lvl2_node.children 90 | assert !lvl2_node.has_children? 91 | assert lvl2_node.is_childless? 92 | # Siblings assertions 93 | assert_equal lvl1_children.map(&:first).map(&:id), lvl2_node.sibling_ids 94 | assert_equal lvl1_children.map(&:first), lvl2_node.siblings 95 | assert lvl2_node.has_siblings? 96 | assert !lvl2_node.is_only_child? 97 | # Descendants assertions 98 | descendants = model.all.find_all do |node| 99 | node.ancestor_ids.include? lvl2_node.id 100 | end 101 | assert_equal descendants.map(&:id), lvl2_node.descendant_ids 102 | assert_equal descendants, lvl2_node.descendants 103 | assert_equal [lvl2_node] + descendants, lvl2_node.subtree 104 | end 105 | end 106 | end 107 | end 108 | end 109 | end -------------------------------------------------------------------------------- /test/concerns/arrangement_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../environment' 2 | 3 | class ArrangementTest < ActiveSupport::TestCase 4 | def root_node(model) 5 | model.order(:id).first 6 | end 7 | 8 | def middle_node(model) 9 | root_node(model).children.sort_by(&:id).first 10 | end 11 | 12 | def leaf_node(model) 13 | model.order("id DESC").first 14 | end 15 | 16 | # Walk the tree of arranged nodes and measure the number of children and 17 | # the expected ids at each depth 18 | def assert_tree(arranged_nodes, size_at_depth) 19 | return if size_at_depth.empty? 20 | 21 | assert_equal size_at_depth[0], arranged_nodes.size 22 | arranged_nodes.each do |node, children| 23 | assert_equal size_at_depth[1], children.size 24 | assert_equal node.children.sort_by(&:id), children.keys.sort_by(&:id) 25 | 26 | assert_tree(children, size_at_depth[1..-1]) 27 | end 28 | end 29 | 30 | # Walk the tree of arranged nodes (which should be a single path) and measure 31 | # the number of children and the expected ids at each depth 32 | def assert_tree_path(arranged_nodes, expected_ids) 33 | if expected_ids.empty? 34 | assert_equal 0, arranged_nodes.size 35 | return 36 | end 37 | 38 | assert_equal 1, arranged_nodes.size 39 | arranged_nodes.each do |node, children| 40 | assert_equal expected_ids[0], node.id 41 | 42 | assert_tree_path(children, expected_ids[1..-1]) 43 | end 44 | end 45 | 46 | def test_arrangement 47 | AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, roots| 48 | assert_tree model.arrange, [3, 3, 3, 0] 49 | end 50 | end 51 | 52 | def test_subtree_arrange_root_node 53 | AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, roots| 54 | assert_tree root_node(model).subtree.arrange, [1, 2, 2, 0] 55 | end 56 | end 57 | 58 | def test_subtree_arrange_middle_node 59 | AncestryTestDatabase.with_model :depth => 4, :width => 2 do |model, roots| 60 | assert_tree middle_node(model).subtree.arrange, [1, 2, 2, 0] 61 | end 62 | end 63 | 64 | def test_subtree_arrange_leaf_node 65 | AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, roots| 66 | assert_tree leaf_node(model).subtree.arrange, [1, 0] 67 | end 68 | end 69 | 70 | def test_descendants_arrange_root_node 71 | AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, roots| 72 | assert_tree root_node(model).descendants.arrange, [2, 2, 0] 73 | end 74 | end 75 | 76 | def test_descendants_arrange_middle_node 77 | AncestryTestDatabase.with_model :depth => 4, :width => 2 do |model, roots| 78 | assert_tree middle_node(model).descendants.arrange, [2, 2, 0] 79 | end 80 | end 81 | 82 | def test_descendants_arrange_leaf_node 83 | AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, roots| 84 | assert_tree leaf_node(model).descendants.arrange, [0] 85 | end 86 | end 87 | 88 | def test_path_arrange_root_node 89 | AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, roots| 90 | test_node = root_node(model) 91 | assert_tree_path test_node.path.arrange, test_node.path_ids 92 | end 93 | end 94 | 95 | def test_path_arrange_middle_node 96 | AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, roots| 97 | test_node = middle_node(model) 98 | assert_tree_path test_node.path.arrange, test_node.path_ids 99 | end 100 | end 101 | 102 | def test_path_arrange_leaf_node 103 | AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, roots| 104 | test_node = leaf_node(model) 105 | assert_tree_path test_node.path.arrange, test_node.path_ids 106 | end 107 | end 108 | 109 | def test_ancestors_arrange_root_node 110 | AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, roots| 111 | test_node = root_node(model) 112 | assert_tree_path test_node.ancestors.arrange, test_node.ancestor_ids 113 | end 114 | end 115 | 116 | def test_ancestors_arrange_middle_node 117 | AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, roots| 118 | test_node = middle_node(model) 119 | assert_tree_path test_node.ancestors.arrange, test_node.ancestor_ids 120 | end 121 | end 122 | 123 | def test_ancestors_arrange_leaf_node 124 | AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, roots| 125 | test_node = leaf_node(model) 126 | assert_tree_path test_node.ancestors.arrange, test_node.ancestor_ids 127 | end 128 | end 129 | 130 | def test_arrange_serializable 131 | AncestryTestDatabase.with_model :depth => 2, :width => 2 do |model, roots| 132 | result = [{"ancestry"=>nil, 133 | "id"=>4, 134 | "children"=> 135 | [{"ancestry"=>"4", "id"=>6, "children"=>[]}, 136 | {"ancestry"=>"4", "id"=>5, "children"=>[]}]}, 137 | {"ancestry"=>nil, 138 | "id"=>1, 139 | "children"=> 140 | [{"ancestry"=>"1", "id"=>3, "children"=>[]}, 141 | {"ancestry"=>"1", "id"=>2, "children"=>[]}]}] 142 | 143 | assert_equal model.arrange_serializable(order: "id desc"), result 144 | end 145 | end 146 | 147 | def test_arrange_serializable_with_block 148 | AncestryTestDatabase.with_model :depth => 2, :width => 2 do |model, roots| 149 | expected_result = [{ 150 | "id"=>4, 151 | "children"=> 152 | [{"id"=>6}, 153 | {"id"=>5}]}, 154 | { 155 | "id"=>1, 156 | "children"=> 157 | [{"id"=>3}, 158 | {"id"=>2}]}] 159 | result = model.arrange_serializable(order: "id desc") do |parent, children| 160 | out = {} 161 | out["id"] = parent.id 162 | out["children"] = children if children.count > 1 163 | out 164 | end 165 | assert_equal result, expected_result 166 | end 167 | end 168 | 169 | def test_arrange_order_option 170 | AncestryTestDatabase.with_model :width => 3, :depth => 3 do |model, roots| 171 | descending_nodes_lvl0 = model.arrange :order => 'id desc' 172 | ascending_nodes_lvl0 = model.arrange :order => 'id asc' 173 | 174 | descending_nodes_lvl0.keys.zip(ascending_nodes_lvl0.keys.reverse).each do |descending_node1, ascending_node1| 175 | assert_equal descending_node1, ascending_node1 176 | descending_nodes_lvl1 = descending_nodes_lvl0[descending_node1] 177 | ascending_nodes_lvl1 = ascending_nodes_lvl0[ascending_node1] 178 | descending_nodes_lvl1.keys.zip(ascending_nodes_lvl1.keys.reverse).each do |descending_node2, ascending_node2| 179 | assert_equal descending_node2, ascending_node2 180 | descending_nodes_lvl2 = descending_nodes_lvl1[descending_node2] 181 | ascending_nodes_lvl2 = ascending_nodes_lvl1[ascending_node2] 182 | descending_nodes_lvl2.keys.zip(ascending_nodes_lvl2.keys.reverse).each do |descending_node3, ascending_node3| 183 | assert_equal descending_node3, ascending_node3 184 | descending_nodes_lvl3 = descending_nodes_lvl2[descending_node3] 185 | ascending_nodes_lvl3 = ascending_nodes_lvl2[ascending_node3] 186 | descending_nodes_lvl3.keys.zip(ascending_nodes_lvl3.keys.reverse).each do |descending_node4, ascending_node4| 187 | assert_equal descending_node4, ascending_node4 188 | end 189 | end 190 | end 191 | end 192 | end 193 | end 194 | 195 | def test_arrangement_nesting 196 | AncestryTestDatabase.with_model :extra_columns => {:name => :string} do |model| 197 | 198 | # Rails < 3.1 doesn't support lambda default_scopes (only hashes) 199 | # But Rails >= 4 logs deprecation warnings for hash default_scopes 200 | if ActiveRecord::VERSION::STRING < "3.1" 201 | model.send :default_scope, model.order('name') 202 | else 203 | model.send :default_scope, lambda { model.order('name') } 204 | end 205 | 206 | model.create!(:name => 'Linux').children.create! :name => 'Debian' 207 | 208 | assert_equal 1, model.arrange.count 209 | end 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Ancestry Changelog 2 | 3 | Doing our best at supporting [SemVer](http://semver.org/) with 4 | a nice looking [Changelog](keepachangelog.com). 5 | 6 | ## Version [Unreleased] ... 7 | 8 | ## Version [3.0.0] 2017-05-18 9 | 10 | ## Changed 11 | 12 | * Dropping Rails 3.0, and 3.1. Added Rails 5.1 support (thx @ledermann) 13 | * Dropping Rails 4.0, 4.1 for build reasons. Since 4.2 is supported, all 4.x should still work. 14 | 15 | ## Fixes 16 | 17 | * Performance: Use `pluck` vs `map` for ids (thx @njakobsen and @culturecode) 18 | * Fixed acts_as_tree compatibility (thx @crazymykl) 19 | * Fixed loading ActiveRails prematurely (thx @vovimayhem) 20 | * Fixes exist (thx @ledermann) 21 | * Properly touches parents when different class for STI (thx @samtgarson) 22 | * Fixed issues with parent_id (only present on master) (thx @domcleal) 23 | 24 | ## Version [2.2.2] 2016-11-01 25 | 26 | ### Changed 27 | 28 | * Use `COALESCE` only for sorting versions greater than 5.0 29 | * Fixed bug with explicit order clauses (introduced in 2.2.0) 30 | * No longer load schema on `has_ancestry` load (thx @ledermann) 31 | 32 | ## Version [2.2.1] 2016-10-25 33 | 34 | Sorry for blip, local master got out of sync with upstream master. 35 | Missed 2 commits (which are feature adds) 36 | 37 | ### Added 38 | * Use like (vs ilike) for rails 5.0 (performance enhancement) 39 | * Use `COALESCE` for sorting on pg, mysql, and sqlite vs `CASE` 40 | 41 | ## Version [2.2.0] 2016-10-25 42 | 43 | ### Added 44 | * Predicates for scopes: e.g.: `ancestor_of?`, `parent_of?` (thx @neglectedvalue) 45 | * Scope `path_of` 46 | 47 | ### Changed 48 | * `arrange` now accepts blocks (thx @mastfish) 49 | * Performance tuning `arrange_node` (thx @fryguy) 50 | * In orphan strategy, set `ancestry` to `nil` for no parents (thx @haslinger) 51 | * Only updates `updated_at` when a record is changed (thx @brocktimus) 52 | * No longer casts text primary key as an integer 53 | * Upgrading tests for ruby versions (thx @brocktimus, @fryguy, @yui-knk) 54 | * Fix non-default ancestry not getting used properly (thx @javiyu) 55 | 56 | ## Version [2.1.0] 2014-04-16 57 | * Added arrange_serializable (thx @krishandley, @chicagogrrl) 58 | * Add the :touch to update ancestors on save (thx @adammck) 59 | * Change conditions into arel (thx @mlitwiniuk) 60 | * Added children? & siblings? alias (thx @bigtunacan) 61 | * closure_tree compatibility (thx @gzigzigzeo) 62 | * Performance tweak (thx @mjc) 63 | * Improvements to organization (thx @xsuchy, @ryakh) 64 | 65 | ## Version [2.0.0] 2013-05-17 66 | * Removed rails 2 compatibility 67 | * Added table name to condition constructing methods (thx @aflatter) 68 | * Fix depth_cache not being updated when moving up to ancestors (thx @scottatron) 69 | * add alias :root? to existing is_root? (thx @divineforest) 70 | * Add block to sort_by_ancestry (thx @Iliya) 71 | * Add attribute query method for parent_id (thx @sj26) 72 | * Fixed and tested for rails 4 (thx @adammck, @Nihad, @Systho, @Philippe, e.a.) 73 | * Fixed overwriting ActiveRecord::Base.base_class (thx @Rozhnov) 74 | * New adopt strategy (thx unknown) 75 | * Many more improvements 76 | 77 | ## Version [1.3.0] 2012-05-04 78 | * Ancestry now ignores default scopes when moving or destroying nodes, ensuring tree consistency 79 | * Changed ActiveRecord dependency to 2.3.14 80 | 81 | ## Version [1.2.5] 2012-03-15 82 | * Fixed warnings: "parenthesize argument(s) for future version" 83 | * Fixed a bug in the restore_ancestry_integrity! method (thx Arthur Holstvoogd) 84 | 85 | ## Version [1.2.4] 2011-04-22 86 | * Prepended table names to column names in queries (thx @raelik) 87 | * Better check to see if acts_as_tree can be overloaded (thx @jims) 88 | * Performance inprovements (thx @kueda) 89 | 90 | ## Version [1.2.3] 2010-10-28 91 | * Fixed error with determining ActiveRecord version 92 | * Added option to specify :primary_key_format (thx @rolftimmermans) 93 | 94 | ## Version [1.2.2] 2010-10-24 95 | * Fixed all deprecation warnings for rails 3.0.X 96 | * Added `:report` option to `check_ancestry_integrity!` 97 | * Changed ActiveRecord dependency to 2.2.2 98 | * Tested and fixed for ruby 1.8.7 and 1.9.2 99 | * Changed usage of `update_attributes` to `update_attribute` to allow ancestry column protection 100 | 101 | ## Version [1.2.0] 2009-11-07 102 | * Removed some duplication in has_ancestry 103 | * Cleaned up plugin pattern according to http://yehudakatz.com/2009/11/12/better-ruby-idioms/ 104 | * Moved parts of ancestry into seperate files 105 | * Made it possible to pass options into the arrange method 106 | * Renamed acts_as_tree to has_ancestry 107 | * Aliased has_ancestry as acts_as_tree if acts_as_tree is available 108 | * Added subtree_of scope 109 | * Updated ordered_by_ancestry scope to support Microsoft SQL Server 110 | * Added empty hash as parameter to exists? calls for older ActiveRecord versions 111 | 112 | ## Version [1.1.4] 2009-11-07 113 | * Thanks to a patch from tom taylor, Ancestry now works with different primary keys 114 | 115 | ## Version [1.1.3] 2009-11-01 116 | * Fixed a pretty bad bug where several operations took far too many queries 117 | 118 | ## Version [1.1.2] 2009-10-29 119 | * Added validation for depth cache column 120 | * Added STI support (reported broken) 121 | 122 | ## Version [1.1.1] 2009-10-28 123 | * Fixed some parentheses warnings that where reported 124 | * Fixed a reported issue with arrangement 125 | * Fixed issues with ancestors and path order on postgres 126 | * Added ordered_by_ancestry scope (needed to fix issues) 127 | 128 | ## Version [1.1.0] 2009-10-22 129 | * Depth caching (and cache rebuilding) 130 | * Depth method for nodes 131 | * Named scopes for selecting by depth 132 | * Relative depth options for tree navigation methods: 133 | * ancestors 134 | * path 135 | * descendants 136 | * descendant_ids 137 | * subtree 138 | * subtree_ids 139 | * Updated README 140 | * Easy migration from existing plugins/gems 141 | * acts_as_tree checks unknown options 142 | * acts_as_tree checks that options are hash 143 | * Added a bang (!) to the integrity functions 144 | * Since these functions should only be used from ./script/console and not 145 | from your application, this change is not considered as breaking backwards 146 | compatibility and the major version wasn't bumped. 147 | * Updated install script to point to documentation 148 | * Removed rails specific init 149 | * Removed uninstall script 150 | 151 | ## Version 1.0.0 2009-10-16 152 | * Initial version 153 | * Tree building 154 | * Tree navigation 155 | * Integrity checking / restoration 156 | * Arrangement 157 | * Orphan strategies 158 | * Subtree movement 159 | * Named scopes 160 | * Validations 161 | 162 | 163 | [Unreleased]: https://github.com/stefankroes/ancestry/compare/v3.0.0...HEAD 164 | [3.0.0]: https://github.com/stefankroes/ancestry/compare/v2.2.2...v3.0.0 165 | [2.2.2]: https://github.com/stefankroes/ancestry/compare/v2.2.1...v2.2.2 166 | [2.2.1]: https://github.com/stefankroes/ancestry/compare/v2.2.0...v2.2.1 167 | [2.2.0]: https://github.com/stefankroes/ancestry/compare/v2.1.0...v2.2.0 168 | [2.1.0]: https://github.com/stefankroes/ancestry/compare/v2.0.0...v2.1.0 169 | [2.0.0]: https://github.com/stefankroes/ancestry/compare/v1.3.0...v2.0.0 170 | [1.3.0]: https://github.com/stefankroes/ancestry/compare/v1.2.5...v1.3.0 171 | [1.2.5]: https://github.com/stefankroes/ancestry/compare/v1.2.4...v1.2.5 172 | [1.2.4]: https://github.com/stefankroes/ancestry/compare/v1.2.3...v1.2.4 173 | [1.2.3]: https://github.com/stefankroes/ancestry/compare/v1.2.2...v1.2.3 174 | [1.2.2]: https://github.com/stefankroes/ancestry/compare/v1.2.0...v1.2.2 175 | [1.2.0]: https://github.com/stefankroes/ancestry/compare/v1.1.4...v1.2.0 176 | [1.1.4]: https://github.com/stefankroes/ancestry/compare/v1.1.3...v1.1.4 177 | [1.1.3]: https://github.com/stefankroes/ancestry/compare/v1.1.2...v1.1.3 178 | [1.1.2]: https://github.com/stefankroes/ancestry/compare/v1.1.1...v1.1.2 179 | [1.1.1]: https://github.com/stefankroes/ancestry/compare/v1.1.0...v1.1.1 180 | [1.1.0]: https://github.com/stefankroes/ancestry/compare/v1.0.0...v1.1.0 181 | -------------------------------------------------------------------------------- /lib/ancestry/class_methods.rb: -------------------------------------------------------------------------------- 1 | module Ancestry 2 | module ClassMethods 3 | # Fetch tree node if necessary 4 | def to_node object 5 | if object.is_a?(self.ancestry_base_class) then object else find(object) end 6 | end 7 | 8 | # Scope on relative depth options 9 | def scope_depth depth_options, depth 10 | depth_options.inject(self.ancestry_base_class) do |scope, option| 11 | scope_name, relative_depth = option 12 | if [:before_depth, :to_depth, :at_depth, :from_depth, :after_depth].include? scope_name 13 | scope.send scope_name, depth + relative_depth 14 | else 15 | raise Ancestry::AncestryException.new("Unknown depth option: #{scope_name}.") 16 | end 17 | end 18 | end 19 | 20 | # Orphan strategy writer 21 | def orphan_strategy= orphan_strategy 22 | # Check value of orphan strategy, only rootify, adopt, restrict or destroy is allowed 23 | if [:rootify, :adopt, :restrict, :destroy].include? orphan_strategy 24 | class_variable_set :@@orphan_strategy, orphan_strategy 25 | else 26 | raise Ancestry::AncestryException.new("Invalid orphan strategy, valid ones are :rootify,:adopt, :restrict and :destroy.") 27 | end 28 | end 29 | 30 | # Arrangement 31 | def arrange options = {} 32 | # Get all nodes ordered by ancestry and start sorting them into an empty hash 33 | arrange_nodes self.ancestry_base_class.reorder(options.delete(:order)).where(options) 34 | end 35 | 36 | # Arrange array of nodes into a nested hash of the form 37 | # {node => children}, where children = {} if the node has no children 38 | def arrange_nodes(nodes) 39 | arranged = ActiveSupport::OrderedHash.new 40 | min_depth = Float::INFINITY 41 | index = Hash.new { |h, k| h[k] = ActiveSupport::OrderedHash.new } 42 | 43 | nodes.each do |node| 44 | children = index[node.id] 45 | index[node.parent_id][node] = children 46 | 47 | depth = node.depth 48 | if depth < min_depth 49 | min_depth = depth 50 | arranged.clear 51 | end 52 | arranged[node] = children if depth == min_depth 53 | end 54 | 55 | arranged 56 | end 57 | 58 | # Arrangement to nested array 59 | def arrange_serializable options={}, nodes=nil, &block 60 | nodes = arrange(options) if nodes.nil? 61 | nodes.map do |parent, children| 62 | if block_given? 63 | yield parent, arrange_serializable(options, children, &block) 64 | else 65 | parent.serializable_hash.merge 'children' => arrange_serializable(options, children) 66 | end 67 | end 68 | end 69 | 70 | # Pseudo-preordered array of nodes. Children will always follow parents, 71 | # for ordering nodes within a rank provide block, eg. Node.sort_by_ancestry(Node.all) {|a, b| a.rank <=> b.rank}. 72 | def sort_by_ancestry(nodes, &block) 73 | arranged = nodes if nodes.is_a?(Hash) 74 | 75 | unless arranged 76 | presorted_nodes = nodes.sort do |a, b| 77 | a_cestry, b_cestry = a.ancestry || '0', b.ancestry || '0' 78 | 79 | if block_given? && a_cestry == b_cestry 80 | yield a, b 81 | else 82 | a_cestry <=> b_cestry 83 | end 84 | end 85 | 86 | arranged = arrange_nodes(presorted_nodes) 87 | end 88 | 89 | arranged.inject([]) do |sorted_nodes, pair| 90 | node, children = pair 91 | sorted_nodes << node 92 | sorted_nodes += sort_by_ancestry(children, &block) unless children.blank? 93 | sorted_nodes 94 | end 95 | end 96 | 97 | # Integrity checking 98 | def check_ancestry_integrity! options = {} 99 | parents = {} 100 | exceptions = [] if options[:report] == :list 101 | 102 | self.ancestry_base_class.unscoped do 103 | # For each node ... 104 | self.ancestry_base_class.find_each do |node| 105 | begin 106 | # ... check validity of ancestry column 107 | if !node.valid? and !node.errors[node.class.ancestry_column].blank? 108 | raise Ancestry::AncestryIntegrityException.new("Invalid format for ancestry column of node #{node.id}: #{node.read_attribute node.ancestry_column}.") 109 | end 110 | # ... check that all ancestors exist 111 | node.ancestor_ids.each do |ancestor_id| 112 | unless exists? ancestor_id 113 | raise Ancestry::AncestryIntegrityException.new("Reference to non-existent node in node #{node.id}: #{ancestor_id}.") 114 | end 115 | end 116 | # ... check that all node parents are consistent with values observed earlier 117 | node.path_ids.zip([nil] + node.path_ids).each do |node_id, parent_id| 118 | parents[node_id] = parent_id unless parents.has_key? node_id 119 | unless parents[node_id] == parent_id 120 | raise Ancestry::AncestryIntegrityException.new("Conflicting parent id found in node #{node.id}: #{parent_id || 'nil'} for node #{node_id} while expecting #{parents[node_id] || 'nil'}") 121 | end 122 | end 123 | rescue Ancestry::AncestryIntegrityException => integrity_exception 124 | case options[:report] 125 | when :list then exceptions << integrity_exception 126 | when :echo then puts integrity_exception 127 | else raise integrity_exception 128 | end 129 | end 130 | end 131 | end 132 | exceptions if options[:report] == :list 133 | end 134 | 135 | # Integrity restoration 136 | def restore_ancestry_integrity! 137 | parents = {} 138 | # Wrap the whole thing in a transaction ... 139 | self.ancestry_base_class.transaction do 140 | self.ancestry_base_class.unscoped do 141 | # For each node ... 142 | self.ancestry_base_class.find_each do |node| 143 | # ... set its ancestry to nil if invalid 144 | if !node.valid? and !node.errors[node.class.ancestry_column].blank? 145 | node.without_ancestry_callbacks do 146 | node.update_attribute node.ancestry_column, nil 147 | end 148 | end 149 | # ... save parent of this node in parents array if it exists 150 | parents[node.id] = node.parent_id if exists? node.parent_id 151 | 152 | # Reset parent id in array to nil if it introduces a cycle 153 | parent = parents[node.id] 154 | until parent.nil? || parent == node.id 155 | parent = parents[parent] 156 | end 157 | parents[node.id] = nil if parent == node.id 158 | end 159 | 160 | # For each node ... 161 | self.ancestry_base_class.find_each do |node| 162 | # ... rebuild ancestry from parents array 163 | ancestry, parent = nil, parents[node.id] 164 | until parent.nil? 165 | ancestry, parent = if ancestry.nil? then parent else "#{parent}/#{ancestry}" end, parents[parent] 166 | end 167 | node.without_ancestry_callbacks do 168 | node.update_attribute node.ancestry_column, ancestry 169 | end 170 | end 171 | end 172 | end 173 | end 174 | 175 | # Build ancestry from parent id's for migration purposes 176 | def build_ancestry_from_parent_ids! parent_id = nil, ancestry = nil 177 | self.ancestry_base_class.unscoped do 178 | self.ancestry_base_class.where(:parent_id => parent_id).find_each do |node| 179 | node.without_ancestry_callbacks do 180 | node.update_attribute ancestry_column, ancestry 181 | end 182 | build_ancestry_from_parent_ids! node.id, if ancestry.nil? then "#{node.id}" else "#{ancestry}/#{node.id}" end 183 | end 184 | end 185 | end 186 | 187 | # Rebuild depth cache if it got corrupted or if depth caching was just turned on 188 | def rebuild_depth_cache! 189 | raise Ancestry::AncestryException.new("Cannot rebuild depth cache for model without depth caching.") unless respond_to? :depth_cache_column 190 | 191 | self.ancestry_base_class.transaction do 192 | self.ancestry_base_class.unscoped do 193 | self.ancestry_base_class.find_each do |node| 194 | node.update_attribute depth_cache_column, node.depth 195 | end 196 | end 197 | end 198 | end 199 | end 200 | end 201 | -------------------------------------------------------------------------------- /lib/ancestry/instance_methods.rb: -------------------------------------------------------------------------------- 1 | module Ancestry 2 | module InstanceMethods 3 | # Validate that the ancestors don't include itself 4 | def ancestry_exclude_self 5 | errors.add(:base, "#{self.class.name.humanize} cannot be a descendant of itself.") if ancestor_ids.include? self.id 6 | end 7 | 8 | # Update descendants with new ancestry 9 | def update_descendants_with_new_ancestry 10 | # If enabled and node is existing and ancestry was updated and the new ancestry is sane ... 11 | if !ancestry_callbacks_disabled? && !new_record? && ancestry_changed? && sane_ancestry? 12 | # ... for each descendant ... 13 | unscoped_descendants.each do |descendant| 14 | # ... replace old ancestry with new ancestry 15 | descendant.without_ancestry_callbacks do 16 | descendant.update_attribute( 17 | self.ancestry_base_class.ancestry_column, 18 | descendant.read_attribute(descendant.class.ancestry_column).gsub( 19 | /^#{self.child_ancestry}/, 20 | if ancestors? then "#{read_attribute self.class.ancestry_column }/#{id}" else id.to_s end 21 | ) 22 | ) 23 | end 24 | end 25 | end 26 | end 27 | 28 | # Apply orphan strategy 29 | def apply_orphan_strategy 30 | if !ancestry_callbacks_disabled? && !new_record? 31 | case self.ancestry_base_class.orphan_strategy 32 | when :rootify # make all children root if orphan strategy is rootify 33 | unscoped_descendants.each do |descendant| 34 | descendant.without_ancestry_callbacks do 35 | new_ancestry = if descendant.ancestry == child_ancestry 36 | nil 37 | else 38 | descendant.ancestry.gsub(/^#{child_ancestry}\//, '') 39 | end 40 | descendant.update_attribute descendant.class.ancestry_column, new_ancestry 41 | end 42 | end 43 | when :destroy # destroy all descendants if orphan strategy is destroy 44 | unscoped_descendants.each do |descendant| 45 | descendant.without_ancestry_callbacks do 46 | descendant.destroy 47 | end 48 | end 49 | when :adopt # make child elements of this node, child of its parent 50 | descendants.each do |descendant| 51 | descendant.without_ancestry_callbacks do 52 | new_ancestry = descendant.ancestor_ids.delete_if { |x| x == self.id }.join("/") 53 | # check for empty string if it's then set to nil 54 | new_ancestry = nil if new_ancestry.empty? 55 | descendant.update_attribute descendant.class.ancestry_column, new_ancestry || nil 56 | end 57 | end 58 | when :restrict # throw an exception if it has children 59 | raise Ancestry::AncestryException.new('Cannot delete record because it has descendants.') unless is_childless? 60 | end 61 | end 62 | end 63 | 64 | # Touch each of this record's ancestors 65 | def touch_ancestors_callback 66 | if !ancestry_callbacks_disabled? && self.ancestry_base_class.touch_ancestors 67 | # Touch each of the old *and* new ancestors 68 | unscoped_current_and_previous_ancestors.each do |ancestor| 69 | ancestor.without_ancestry_callbacks do 70 | ancestor.touch 71 | end 72 | end 73 | end 74 | end 75 | 76 | # The ancestry value for this record's children 77 | def child_ancestry 78 | # New records cannot have children 79 | raise Ancestry::AncestryException.new('No child ancestry for new record. Save record before performing tree operations.') if new_record? 80 | 81 | if self.send("#{self.ancestry_base_class.ancestry_column}_was").blank? then id.to_s else "#{self.send "#{self.ancestry_base_class.ancestry_column}_was"}/#{id}" end 82 | end 83 | 84 | # Ancestors 85 | 86 | def ancestors? 87 | # ancestor_ids.present? 88 | read_attribute(self.ancestry_base_class.ancestry_column).present? 89 | end 90 | 91 | def ancestry_changed? 92 | changed.include?(self.ancestry_base_class.ancestry_column.to_s) 93 | end 94 | 95 | def parse_ancestry_column obj 96 | obj.to_s.split('/').map { |id| cast_primary_key(id) } 97 | end 98 | 99 | def ancestor_ids 100 | parse_ancestry_column(read_attribute(self.ancestry_base_class.ancestry_column)) 101 | end 102 | 103 | def ancestor_conditions 104 | self.ancestry_base_class.ancestor_conditions(self) 105 | end 106 | 107 | def ancestors depth_options = {} 108 | self.ancestry_base_class.scope_depth(depth_options, depth).ordered_by_ancestry.where ancestor_conditions 109 | end 110 | 111 | def ancestor_was_conditions 112 | {primary_key_with_table => ancestor_ids_was} 113 | end 114 | 115 | def ancestor_ids_was 116 | relevant_attributes = if ActiveRecord::VERSION::STRING >= '5.1.0' 117 | saved_changes.transform_values(&:first) 118 | else 119 | changed_attributes 120 | end 121 | 122 | parse_ancestry_column(relevant_attributes[self.ancestry_base_class.ancestry_column.to_s]) 123 | end 124 | 125 | def path_ids 126 | ancestor_ids + [id] 127 | end 128 | 129 | def path_conditions 130 | self.ancestry_base_class.path_conditions(self) 131 | end 132 | 133 | def path depth_options = {} 134 | self.ancestry_base_class.scope_depth(depth_options, depth).ordered_by_ancestry.where path_conditions 135 | end 136 | 137 | def depth 138 | ancestor_ids.size 139 | end 140 | 141 | def cache_depth 142 | write_attribute self.ancestry_base_class.depth_cache_column, depth 143 | end 144 | 145 | def ancestor_of?(node) 146 | node.ancestor_ids.include?(self.id) 147 | end 148 | 149 | # Parent 150 | 151 | def parent= parent 152 | write_attribute(self.ancestry_base_class.ancestry_column, if parent.nil? then nil else parent.child_ancestry end) 153 | end 154 | 155 | def parent_id= new_parent_id 156 | self.parent = new_parent_id.present? ? unscoped_find(new_parent_id) : nil 157 | end 158 | 159 | def parent_id 160 | ancestor_ids.last if ancestors? 161 | end 162 | 163 | def parent 164 | unscoped_find(parent_id) if ancestors? 165 | end 166 | 167 | def parent_id? 168 | ancestors? 169 | end 170 | 171 | def parent_of?(node) 172 | self.id == node.parent_id 173 | end 174 | 175 | # Root 176 | 177 | def root_id 178 | ancestors? ? ancestor_ids.first : id 179 | end 180 | 181 | def root 182 | ancestors? ? unscoped_find(root_id) : self 183 | end 184 | 185 | def is_root? 186 | read_attribute(self.ancestry_base_class.ancestry_column).blank? 187 | end 188 | alias :root? :is_root? 189 | 190 | def root_of?(node) 191 | self.id == node.root_id 192 | end 193 | 194 | # Children 195 | 196 | def child_conditions 197 | self.ancestry_base_class.child_conditions(self) 198 | end 199 | 200 | def children 201 | self.ancestry_base_class.where child_conditions 202 | end 203 | 204 | def child_ids 205 | children.pluck(self.ancestry_base_class.primary_key) 206 | end 207 | 208 | def has_children? 209 | self.children.exists? 210 | end 211 | alias_method :children?, :has_children? 212 | 213 | def is_childless? 214 | !has_children? 215 | end 216 | alias_method :childless?, :is_childless? 217 | 218 | def child_of?(node) 219 | self.parent_id == node.id 220 | end 221 | 222 | # Siblings 223 | 224 | def sibling_conditions 225 | self.ancestry_base_class.sibling_conditions(self) 226 | end 227 | 228 | def siblings 229 | self.ancestry_base_class.where sibling_conditions 230 | end 231 | 232 | def sibling_ids 233 | siblings.pluck(self.ancestry_base_class.primary_key) 234 | end 235 | 236 | def has_siblings? 237 | self.siblings.count > 1 238 | end 239 | alias_method :siblings?, :has_siblings? 240 | 241 | def is_only_child? 242 | !has_siblings? 243 | end 244 | alias_method :only_child?, :is_only_child? 245 | 246 | def sibling_of?(node) 247 | self.ancestry == node.ancestry 248 | end 249 | 250 | # Descendants 251 | 252 | def descendant_conditions 253 | self.ancestry_base_class.descendant_conditions(self) 254 | end 255 | 256 | def descendants depth_options = {} 257 | self.ancestry_base_class.ordered_by_ancestry.scope_depth(depth_options, depth).where descendant_conditions 258 | end 259 | 260 | def descendant_ids depth_options = {} 261 | descendants(depth_options).pluck(self.ancestry_base_class.primary_key) 262 | end 263 | 264 | def descendant_of?(node) 265 | ancestor_ids.include?(node.id) 266 | end 267 | 268 | # Subtree 269 | 270 | def subtree_conditions 271 | self.ancestry_base_class.subtree_conditions(self) 272 | end 273 | 274 | def subtree depth_options = {} 275 | self.ancestry_base_class.ordered_by_ancestry.scope_depth(depth_options, depth).where subtree_conditions 276 | end 277 | 278 | def subtree_ids depth_options = {} 279 | subtree(depth_options).pluck(self.ancestry_base_class.primary_key) 280 | end 281 | 282 | # Callback disabling 283 | 284 | def without_ancestry_callbacks 285 | @disable_ancestry_callbacks = true 286 | yield 287 | @disable_ancestry_callbacks = false 288 | end 289 | 290 | def ancestry_callbacks_disabled? 291 | defined?(@disable_ancestry_callbacks) && @disable_ancestry_callbacks 292 | end 293 | 294 | private 295 | 296 | def cast_primary_key(key) 297 | if [:string, :uuid, :text].include? primary_key_type 298 | key 299 | else 300 | key.to_i 301 | end 302 | end 303 | 304 | def primary_key_type 305 | @primary_key_type ||= column_for_attribute(self.class.primary_key).type 306 | end 307 | 308 | def unscoped_descendants 309 | self.ancestry_base_class.unscoped do 310 | self.ancestry_base_class.where descendant_conditions 311 | end 312 | end 313 | 314 | def unscoped_current_and_previous_ancestors 315 | self.ancestry_base_class.unscoped do 316 | self.ancestry_base_class.where id: (ancestor_ids + ancestor_ids_was).uniq 317 | end 318 | end 319 | 320 | def unscoped_find id 321 | self.ancestry_base_class.unscoped { self.ancestry_base_class.find(id) } 322 | end 323 | end 324 | end 325 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/stefankroes/ancestry.svg?branch=master)](https://travis-ci.org/stefankroes/ancestry) [![Coverage Status](https://coveralls.io/repos/stefankroes/ancestry/badge.svg)](https://coveralls.io/r/stefankroes/ancestry) [![Gitter](https://badges.gitter.im/Join+Chat.svg)](https://gitter.im/stefankroes/ancestry?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Security](https://hakiri.io/github/stefankroes/ancestry/master.svg)](https://hakiri.io/github/stefankroes/ancestry/master) 2 | 3 | # Ancestry 4 | 5 | Ancestry is a gem that allows the records of a Ruby on Rails 6 | ActiveRecord model to be organised as a tree structure (or hierarchy). It uses 7 | a single database column, using the materialised path pattern. It exposes all the standard tree structure 8 | relations (ancestors, parent, root, children, siblings, descendants) and all 9 | of them can be fetched in a single SQL query. Additional features are STI 10 | support, scopes, depth caching, depth constraints, easy migration from older 11 | gems, integrity checking, integrity restoration, arrangement of 12 | (sub)tree into hashes and different strategies for dealing with orphaned 13 | records. 14 | 15 | # Installation 16 | 17 | To apply Ancestry to any `ActiveRecord` model, follow these simple steps: 18 | 19 | ## Install 20 | 21 | * Add to Gemfile: 22 | ```ruby 23 | # Gemfile 24 | 25 | gem 'ancestry' 26 | ``` 27 | 28 | * Install required gems: 29 | ```bash 30 | $ bundle install 31 | ``` 32 | 33 | 34 | ## Add ancestry column to your table 35 | * Create migration: 36 | ```bash 37 | $ rails g migration add_ancestry_to_[table] ancestry:string 38 | ``` 39 | 40 | * Add index to migration: 41 | ```ruby 42 | # db/migrate/[date]_add_ancestry_to_[table].rb 43 | 44 | class AddAncestryTo[Table] < ActiveRecord::Migration 45 | def change 46 | add_column [table], :ancestry, :string 47 | add_index [table], :ancestry 48 | end 49 | end 50 | ``` 51 | 52 | * Migrate your database: 53 | ```bash 54 | $ rake db:migrate 55 | ``` 56 | 57 | 58 | ## Add ancestry to your model 59 | * Add to [app/models/](model).rb: 60 | 61 | ```ruby 62 | # app/models/[model.rb] 63 | 64 | class [Model] < ActiveRecord::Base 65 | has_ancestry 66 | end 67 | ``` 68 | 69 | Your model is now a tree! 70 | 71 | # Using acts_as_tree instead of has_ancestry 72 | 73 | In version 1.2.0 the **acts_as_tree** method was **renamed to has_ancestry** 74 | in order to allow usage of both the acts_as_tree gem and the ancestry gem in a 75 | single application. method `acts_as_tree` will continue to be supported in the future. 76 | 77 | # Organising records into a tree 78 | 79 | You can use the parent attribute to organise your records into a tree. If you 80 | have the id of the record you want to use as a parent and don't want to fetch 81 | it, you can also use parent_id. Like any virtual model attributes, parent and 82 | parent_id can be set using parent= and parent_id= on a record or by including 83 | them in the hash passed to new, create, create!, update_attributes and 84 | update_attributes!. For example: 85 | 86 | ```ruby 87 | TreeNode.create! :name => 'Stinky', :parent => TreeNode.create!(:name => 'Squeeky') 88 | ``` 89 | 90 | You can also create children through the children relation on a node: 91 | 92 | ```ruby 93 | node.children.create :name => 'Stinky' 94 | ``` 95 | 96 | # Navigating your tree 97 | 98 | To navigate an Ancestry model, use the following methods on any instance / 99 | record: 100 | 101 | parent Returns the parent of the record, nil for a root node 102 | parent_id Returns the id of the parent of the record, nil for a root node 103 | root Returns the root of the tree the record is in, self for a root node 104 | root_id Returns the id of the root of the tree the record is in 105 | root?, is_root? Returns true if the record is a root node, false otherwise 106 | ancestor_ids Returns a list of ancestor ids, starting with the root id and ending with the parent id 107 | ancestors Scopes the model on ancestors of the record 108 | path_ids Returns a list the path ids, starting with the root id and ending with the node's own id 109 | path Scopes model on path records of the record 110 | children Scopes the model on children of the record 111 | child_ids Returns a list of child ids 112 | has_children? Returns true if the record has any children, false otherwise 113 | is_childless? Returns true is the record has no children, false otherwise 114 | siblings Scopes the model on siblings of the record, the record itself is included* 115 | sibling_ids Returns a list of sibling ids 116 | has_siblings? Returns true if the record's parent has more than one child 117 | is_only_child? Returns true if the record is the only child of its parent 118 | descendants Scopes the model on direct and indirect children of the record 119 | descendant_ids Returns a list of a descendant ids 120 | subtree Scopes the model on descendants and itself 121 | subtree_ids Returns a list of all ids in the record's subtree 122 | depth Return the depth of the node, root nodes are at depth 0 123 | 124 | * If the record is a root, other root records are considered siblings 125 | 126 | 127 | # Options for `has_ancestry` 128 | 129 | The has_ancestry methods supports the following options: 130 | 131 | :ancestry_column Pass in a symbol to store ancestry in a different column 132 | :orphan_strategy Instruct Ancestry what to do with children of a node that is destroyed: 133 | :destroy All children are destroyed as well (default) 134 | :rootify The children of the destroyed node become root nodes 135 | :restrict An AncestryException is raised if any children exist 136 | :adopt The orphan subtree is added to the parent of the deleted node. 137 | If the deleted node is Root, then rootify the orphan subtree. 138 | :cache_depth Cache the depth of each node in the 'ancestry_depth' column (default: false) 139 | If you turn depth_caching on for an existing model: 140 | - Migrate: add_column [table], :ancestry_depth, :integer, :default => 0 141 | - Build cache: TreeNode.rebuild_depth_cache! 142 | :depth_cache_column Pass in a symbol to store depth cache in a different column 143 | :primary_key_format Supply a regular expression that matches the format of your primary key. 144 | By default, primary keys only match integers ([0-9]+). 145 | :touch Instruct Ancestry to touch the ancestors of a node when it changes, to 146 | invalidate nested key-based caches. (default: false) 147 | 148 | # (Named) Scopes 149 | 150 | Where possible, the navigation methods return scopes instead of records, this 151 | means additional ordering, conditions, limits, etc. can be applied and that 152 | the result can be either retrieved, counted or checked for existence. For 153 | example: 154 | 155 | ```ruby 156 | node.children.where(:name => 'Mary').exists? 157 | node.subtree.order(:name).limit(10).each do; ...; end 158 | node.descendants.count 159 | ``` 160 | 161 | For convenience, a couple of named scopes are included at the class level: 162 | 163 | roots Root nodes 164 | ancestors_of(node) Ancestors of node, node can be either a record or an id 165 | children_of(node) Children of node, node can be either a record or an id 166 | descendants_of(node) Descendants of node, node can be either a record or an id 167 | subtree_of(node) Subtree of node, node can be either a record or an id 168 | siblings_of(node) Siblings of node, node can be either a record or an id 169 | 170 | Thanks to some convenient rails magic, it is even possible to create nodes 171 | through the children and siblings scopes: 172 | 173 | node.children.create 174 | node.siblings.create! 175 | TestNode.children_of(node_id).new 176 | TestNode.siblings_of(node_id).create 177 | 178 | # Selecting nodes by depth 179 | 180 | When depth caching is enabled (see has_ancestry options), five more named 181 | scopes can be used to select nodes on their depth: 182 | 183 | before_depth(depth) Return nodes that are less deep than depth (node.depth < depth) 184 | to_depth(depth) Return nodes up to a certain depth (node.depth <= depth) 185 | at_depth(depth) Return nodes that are at depth (node.depth == depth) 186 | from_depth(depth) Return nodes starting from a certain depth (node.depth >= depth) 187 | after_depth(depth) Return nodes that are deeper than depth (node.depth > depth) 188 | 189 | The depth scopes are also available through calls to descendants, 190 | descendant_ids, subtree, subtree_ids, path and ancestors. In this case, depth 191 | values are interpreted relatively. Some examples: 192 | 193 | node.subtree(:to_depth => 2) Subtree of node, to a depth of node.depth + 2 (self, children and grandchildren) 194 | node.subtree.to_depth(5) Subtree of node to an absolute depth of 5 195 | node.descendants(:at_depth => 2) Descendant of node, at depth node.depth + 2 (grandchildren) 196 | node.descendants.at_depth(10) Descendants of node at an absolute depth of 10 197 | node.ancestors.to_depth(3) The oldest 4 ancestors of node (its root and 3 more) 198 | node.path(:from_depth => -2) The node's grandparent, parent and the node itself 199 | 200 | node.ancestors(:from_depth => -6, :to_depth => -4) 201 | node.path.from_depth(3).to_depth(4) 202 | node.descendants(:from_depth => 2, :to_depth => 4) 203 | node.subtree.from_depth(10).to_depth(12) 204 | 205 | Please note that depth constraints cannot be passed to ancestor_ids and 206 | path_ids. The reason for this is that both these relations can be fetched 207 | directly from the ancestry column without performing a database query. It 208 | would require an entirely different method of applying the depth constraints 209 | which isn't worth the effort of implementing. You can use 210 | ancestors(depth_options).map(&:id) or ancestor_ids.slice(min_depth..max_depth) 211 | instead. 212 | 213 | # STI support 214 | 215 | Ancestry works fine with STI. Just create a STI inheritance hierarchy and 216 | build an Ancestry tree from the different classes/models. All Ancestry 217 | relations that where described above will return nodes of any model type. If 218 | you do only want nodes of a specific subclass you'll have to add a condition 219 | on type for that. 220 | 221 | # Arrangement 222 | 223 | Ancestry can arrange an entire subtree into nested hashes for easy navigation 224 | after retrieval from the database. TreeNode.arrange could for example return: 225 | 226 | ```ruby 227 | { # 228 | => { # 229 | => { # 230 | => {} 231 | } 232 | } 233 | } 234 | ``` 235 | 236 | The arrange method also works on a scoped class, for example: 237 | 238 | ```ruby 239 | TreeNode.find_by_name('Crunchy').subtree.arrange 240 | ``` 241 | 242 | The arrange method takes `ActiveRecord` find options. If you want your hashes to 243 | be ordered, you should pass the order to the arrange method instead of to the 244 | scope. example: 245 | 246 | ```ruby 247 | TreeNode.find_by_name('Crunchy').subtree.arrange(:order => :name) 248 | ``` 249 | 250 | To get the arranged nodes as a nested array of hashes for serialization: 251 | 252 | TreeNode.arrange_serializable 253 | 254 | ```ruby 255 | [ 256 | { 257 | "ancestry" => nil, "id" => 1, "children" => [ 258 | { "ancestry" => "1", "id" => 2, "children" => [] } 259 | ] 260 | } 261 | ] 262 | ``` 263 | 264 | You can also supply your own serialization logic using blocks: 265 | 266 | For example, using `ActiveModel` Serializers: 267 | 268 | ```ruby 269 | TreeNode.arrange_serializable do |parent, children| 270 | MySerializer.new(parent, children: children) 271 | end 272 | ``` 273 | 274 | Or plain hashes: 275 | 276 | ```ruby 277 | TreeNode.arrange_serializable do |parent, children| 278 | { 279 | my_id: parent.id 280 | my_children: children 281 | } 282 | end 283 | ``` 284 | 285 | The result of arrange_serializable can easily be serialized to json with 286 | `to_json`, or some other format: 287 | 288 | ``` 289 | TreeNode.arrange_serializable.to_json 290 | ``` 291 | 292 | You can also pass the order to the arrange_serializable method just as you can 293 | pass it to the arrange method: 294 | 295 | ``` 296 | TreeNode.arrange_serializable(:order => :name) 297 | ``` 298 | 299 | # Sorting 300 | 301 | If you just want to sort an array of nodes as if you were traversing them in 302 | preorder, you can use the sort_by_ancestry class method: 303 | 304 | ``` 305 | TreeNode.sort_by_ancestry(array_of_nodes) 306 | ``` 307 | 308 | Note that since materialised path trees don't support ordering within a rank, 309 | the order of siblings depends on their order in the original array. 310 | 311 | # Migrating from plugin that uses parent_id column 312 | 313 | Most current tree plugins use a parent_id column (has_ancestry, 314 | awesome_nested_set, better_nested_set, acts_as_nested_set). With ancestry its 315 | easy to migrate from any of these plugins, to do so, use the 316 | build_ancestry_from_parent_ids! method on your ancestry model. These steps 317 | provide a more detailed explanation: 318 | 319 | 1. Add ancestry column to your table 320 | * Create migration: **rails g migration [add_ancestry_to_](table) 321 | ancestry:string** 322 | * Add index to migration: **add_index [table], :ancestry** (UP) / 323 | **remove_index [table], :ancestry** (DOWN) 324 | * Migrate your database: **rake db:migrate** 325 | 326 | 327 | 2. Remove old tree gem and add in Ancestry to `Gemfile` 328 | * See 'Installation' for more info on installing and configuring gems 329 | 330 | 331 | 3. Change your model 332 | * Remove any macros required by old plugin/gem from 333 | `[app/models/](model).rb` 334 | * Add to `[app/models/](model).rb`: `has_ancestry` 335 | 336 | 337 | 4. Generate ancestry columns 338 | * In './script.console': **[model].build_ancestry_from_parent_ids!** 339 | * Make sure it worked ok: **[model].check_ancestry_integrity!** 340 | 341 | 342 | 5. Change your code 343 | * Most tree calls will probably work fine with ancestry 344 | * Others must be changed or proxied 345 | * Check if all your data is intact and all tests pass 346 | 347 | 348 | 6. Drop parent_id column: 349 | * Create migration: `rails g migration [remove_parent_id_from_](table)` 350 | * Add to migration: `remove_column [table], :parent_id` 351 | * Migrate your database: `rake db:migrate` 352 | 353 | # Integrity checking and restoration 354 | 355 | I don't see any way Ancestry tree integrity could get compromised without 356 | explicitly setting cyclic parents or invalid ancestry and circumventing 357 | validation with update_attribute, if you do, please let me know. 358 | 359 | Ancestry includes some methods for detecting integrity problems and restoring 360 | integrity just to be sure. To check integrity use: 361 | [Model].check_ancestry_integrity!. An AncestryIntegrityException will be 362 | raised if there are any problems. You can also specify :report => :list to 363 | return an array of exceptions or :report => :echo to echo any error messages. 364 | To restore integrity use: [Model].restore_ancestry_integrity!. 365 | 366 | For example, from IRB: 367 | 368 | ``` 369 | >> stinky = TreeNode.create :name => 'Stinky' 370 | $ # 371 | >> squeeky = TreeNode.create :name => 'Squeeky', :parent => stinky 372 | $ # 373 | >> stinky.update_attribute :parent, squeeky 374 | $ true 375 | >> TreeNode.all 376 | $ [#, #] 377 | >> TreeNode.check_ancestry_integrity! 378 | !! Ancestry::AncestryIntegrityException: Conflicting parent id in node 1: 2 for node 1, expecting nil 379 | >> TreeNode.restore_ancestry_integrity! 380 | $ [#, #] 381 | ``` 382 | 383 | Additionally, if you think something is wrong with your depth cache: 384 | 385 | ``` 386 | >> TreeNode.rebuild_depth_cache! 387 | ``` 388 | 389 | # Running Tests 390 | 391 | ```bash 392 | git clone git@github.com:stefankroes/ancestry.git 393 | cd ancestry 394 | cp test/database.example.yml test/database.yml 395 | bundle 396 | appraisal install 397 | # all tests 398 | appraisal rake test 399 | # single test version (sqlite and rails 5.0) 400 | appraisal sqlite3-ar-50 rake test 401 | ``` 402 | 403 | # Internals 404 | 405 | Ancestry stores a path from the root to the parent for every node. 406 | This is a variation on the materialised path database pattern. 407 | It allows Ancestry to fetch any relation (siblings, 408 | descendants, etc.) in a single SQL query without the complicated algorithms 409 | and incomprehensibility associated with left and right values. Additionally, 410 | any inserts, deletes and updates only affect nodes within the affected node's 411 | own subtree. 412 | 413 | In the example above, the `ancestry` column is created as a `string`. This puts a 414 | limitation on the depth of the tree of about 40 or 50 levels. To increase the 415 | maximum depth of the tree, increase the size of the `string` or use `text` to 416 | remove the limitation entirely. Changing it to a text will however decrease 417 | performance because an index cannot be put on the column in that case. 418 | 419 | The materialised path pattern requires Ancestry to use a 'like' condition in 420 | order to fetch descendants. The wild character (`%`) is on the left of the 421 | query, so indexes should be used. 422 | 423 | # Contributing and license 424 | 425 | Question? Bug report? Faulty/incomplete documentation? Feature request? Please 426 | post an issue on 'http://github.com/stefankroes/ancestry/issues'. Make sure 427 | you have read the documentation and you have included tests and documentation 428 | with any pull request. 429 | 430 | Copyright (c) 2016 Stefan Kroes, released under the MIT license 431 | --------------------------------------------------------------------------------