├── 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 | [](https://travis-ci.org/stefankroes/ancestry) [](https://coveralls.io/r/stefankroes/ancestry) [](https://gitter.im/stefankroes/ancestry?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [](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 |
--------------------------------------------------------------------------------