closure_tree 7.4.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +30 -56
- data/.github/workflows/ci_jruby.yml +68 -0
- data/.github/workflows/ci_truffleruby.yml +71 -0
- data/.github/workflows/release.yml +17 -0
- data/.gitignore +1 -1
- data/.release-please-manifest.json +1 -0
- data/.tool-versions +1 -0
- data/Appraisals +9 -53
- data/CHANGELOG.md +5 -0
- data/Gemfile +2 -3
- data/README.md +21 -9
- data/Rakefile +11 -16
- data/closure_tree.gemspec +16 -9
- data/lib/closure_tree/active_record_support.rb +3 -14
- data/lib/closure_tree/digraphs.rb +1 -1
- data/lib/closure_tree/finders.rb +1 -1
- data/lib/closure_tree/hash_tree.rb +1 -1
- data/lib/closure_tree/hierarchy_maintenance.rb +3 -6
- data/lib/closure_tree/model.rb +3 -3
- data/lib/closure_tree/numeric_deterministic_ordering.rb +3 -8
- data/lib/closure_tree/support.rb +3 -7
- data/lib/closure_tree/version.rb +1 -1
- data/lib/generators/closure_tree/migration_generator.rb +1 -4
- data/release-please-config.json +4 -0
- data/test/closure_tree/cache_invalidation_test.rb +36 -0
- data/test/closure_tree/cuisine_type_test.rb +42 -0
- data/test/closure_tree/generator_test.rb +49 -0
- data/test/closure_tree/has_closure_tree_root_test.rb +80 -0
- data/test/closure_tree/hierarchy_maintenance_test.rb +56 -0
- data/test/closure_tree/label_test.rb +674 -0
- data/test/closure_tree/metal_test.rb +59 -0
- data/test/closure_tree/model_test.rb +9 -0
- data/test/closure_tree/namespace_type_test.rb +13 -0
- data/test/closure_tree/parallel_test.rb +162 -0
- data/test/closure_tree/pool_test.rb +33 -0
- data/test/closure_tree/support_test.rb +18 -0
- data/test/closure_tree/tag_test.rb +8 -0
- data/test/closure_tree/user_test.rb +175 -0
- data/test/closure_tree/uuid_tag_test.rb +8 -0
- data/test/support/query_counter.rb +25 -0
- data/test/support/tag_examples.rb +923 -0
- data/test/test_helper.rb +99 -0
- metadata +52 -21
- data/_config.yml +0 -1
- data/tests.sh +0 -11
data/lib/closure_tree/model.rb
CHANGED
@@ -6,12 +6,12 @@ module ClosureTree
|
|
6
6
|
|
7
7
|
included do
|
8
8
|
|
9
|
-
belongs_to :parent, nil,
|
9
|
+
belongs_to :parent, nil,
|
10
10
|
class_name: _ct.model_class.to_s,
|
11
11
|
foreign_key: _ct.parent_column_name,
|
12
12
|
inverse_of: :children,
|
13
13
|
touch: _ct.options[:touch],
|
14
|
-
optional: true
|
14
|
+
optional: true
|
15
15
|
|
16
16
|
order_by_generations = -> { Arel.sql("#{_ct.quoted_hierarchy_table_name}.generations ASC") }
|
17
17
|
|
@@ -77,7 +77,7 @@ module ClosureTree
|
|
77
77
|
end
|
78
78
|
|
79
79
|
def depth
|
80
|
-
|
80
|
+
ancestor_hierarchies.size - 1
|
81
81
|
end
|
82
82
|
|
83
83
|
alias_method :level, :depth
|
@@ -10,13 +10,8 @@ module ClosureTree
|
|
10
10
|
end
|
11
11
|
|
12
12
|
def _ct_reorder_prior_siblings_if_parent_changed
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
if public_send(change_method, _ct.parent_column_name) && !@was_new_record
|
17
|
-
attribute_method = as_5_1 ? :attribute_before_last_save : :attribute_was
|
18
|
-
|
19
|
-
was_parent_id = public_send(attribute_method, _ct.parent_column_name)
|
13
|
+
if public_send(:saved_change_to_attribute?, _ct.parent_column_name) && !@was_new_record
|
14
|
+
was_parent_id = public_send(:attribute_before_last_save, _ct.parent_column_name)
|
20
15
|
_ct.reorder_with_parent_id(was_parent_id)
|
21
16
|
end
|
22
17
|
end
|
@@ -47,7 +42,7 @@ module ClosureTree
|
|
47
42
|
.reorder(self.class._ct_sum_order_by(self))
|
48
43
|
end
|
49
44
|
|
50
|
-
|
45
|
+
class_methods do
|
51
46
|
|
52
47
|
# If node is nil, order the whole tree.
|
53
48
|
def _ct_sum_order_by(node = nil)
|
data/lib/closure_tree/support.rb
CHANGED
@@ -32,7 +32,7 @@ module ClosureTree
|
|
32
32
|
end
|
33
33
|
|
34
34
|
def hierarchy_class_for_model
|
35
|
-
parent_class =
|
35
|
+
parent_class = model_class.module_parent
|
36
36
|
hierarchy_class = parent_class.const_set(short_hierarchy_class_name, Class.new(model_class.superclass))
|
37
37
|
use_attr_accessible = use_attr_accessible?
|
38
38
|
include_forbidden_attributes_protection = include_forbidden_attributes_protection?
|
@@ -59,9 +59,9 @@ module ClosureTree
|
|
59
59
|
# because they may have overridden the table name, which is what we want to be consistent with
|
60
60
|
# in order for the schema to make sense.
|
61
61
|
tablename = options[:hierarchy_table_name] ||
|
62
|
-
remove_prefix_and_suffix(table_name).singularize + "_hierarchies"
|
62
|
+
remove_prefix_and_suffix(table_name, model_class).singularize + "_hierarchies"
|
63
63
|
|
64
|
-
|
64
|
+
[model_class.table_name_prefix, tablename, model_class.table_name_suffix].join
|
65
65
|
end
|
66
66
|
|
67
67
|
def with_order_option(opts)
|
@@ -79,10 +79,6 @@ module ClosureTree
|
|
79
79
|
end
|
80
80
|
end
|
81
81
|
|
82
|
-
def belongs_to_with_optional_option(opts)
|
83
|
-
ActiveRecord::VERSION::MAJOR < 5 ? opts.except(:optional) : opts
|
84
|
-
end
|
85
|
-
|
86
82
|
# lambda-ize the order, but don't apply the default order_option
|
87
83
|
def has_many_order_without_option(order_by_opt)
|
88
84
|
[lambda { order(order_by_opt.call) }]
|
data/lib/closure_tree/version.rb
CHANGED
@@ -43,10 +43,7 @@ module ClosureTree
|
|
43
43
|
end
|
44
44
|
|
45
45
|
def migration_version
|
46
|
-
|
47
|
-
if major >= 5
|
48
|
-
"[#{major}.#{ActiveRecord::VERSION::MINOR}]"
|
49
|
-
end
|
46
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
50
47
|
end
|
51
48
|
|
52
49
|
def self.next_migration_number(dirname)
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class CacheInvalidationTest < ActiveSupport::TestCase
|
4
|
+
def setup
|
5
|
+
Timecop.travel(10.seconds.ago) do
|
6
|
+
#create a long tree with 2 branch
|
7
|
+
@root = MenuItem.create(
|
8
|
+
name: SecureRandom.hex(10)
|
9
|
+
)
|
10
|
+
2.times do
|
11
|
+
parent = @root
|
12
|
+
10.times do
|
13
|
+
parent = parent.children.create(
|
14
|
+
name: SecureRandom.hex(10)
|
15
|
+
)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
@first_leaf = MenuItem.leaves.first
|
19
|
+
@second_leaf = MenuItem.leaves.last
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
test "touch option should invalidate cache for all it ancestors" do
|
24
|
+
old_time_stamp = @first_leaf.ancestors.pluck(:updated_at)
|
25
|
+
@first_leaf.touch
|
26
|
+
new_time_stamp = @first_leaf.ancestors.pluck(:updated_at)
|
27
|
+
assert_not_equal old_time_stamp, new_time_stamp, 'Cache not invalidated for all ancestors'
|
28
|
+
end
|
29
|
+
|
30
|
+
test "touch option should not invalidate cache for another branch" do
|
31
|
+
old_time_stamp = @second_leaf.updated_at
|
32
|
+
@first_leaf.touch
|
33
|
+
new_time_stamp = @second_leaf.updated_at
|
34
|
+
assert_equal old_time_stamp, new_time_stamp, 'Cache incorrectly invalidated for another branch'
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'test_helper'
|
4
|
+
|
5
|
+
def assert_lineage(e, m)
|
6
|
+
assert_equal e, m.parent
|
7
|
+
assert_equal [m, e], m.self_and_ancestors
|
8
|
+
|
9
|
+
# make sure reloading doesn't affect the self_and_ancestors:
|
10
|
+
m.reload
|
11
|
+
assert_equal [m, e], m.self_and_ancestors
|
12
|
+
end
|
13
|
+
|
14
|
+
describe CuisineType do
|
15
|
+
it 'finds self and parents when children << is used' do
|
16
|
+
e = CuisineType.new(name: 'e')
|
17
|
+
m = CuisineType.new(name: 'm')
|
18
|
+
e.children << m
|
19
|
+
e.save
|
20
|
+
assert_lineage(e, m)
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'finds self and parents properly if the constructor is used' do
|
24
|
+
e = CuisineType.create(name: 'e')
|
25
|
+
m = CuisineType.create(name: 'm', parent: e)
|
26
|
+
assert_lineage(e, m)
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'sets the table_name of the hierarchy class properly' do
|
30
|
+
assert_equal(
|
31
|
+
"#{ActiveRecord::Base.table_name_prefix}cuisine_type_hierarchies#{ActiveRecord::Base.table_name_suffix}", CuisineTypeHierarchy.table_name
|
32
|
+
)
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'fixes self_and_ancestors properly on reparenting' do
|
36
|
+
a = CuisineType.create! name: 'a'
|
37
|
+
b = CuisineType.create! name: 'b'
|
38
|
+
assert_equal([b], b.self_and_ancestors.to_a)
|
39
|
+
a.children << b
|
40
|
+
assert_equal([b, a], b.self_and_ancestors.to_a)
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'test_helper'
|
4
|
+
require 'generators/closure_tree/migration_generator'
|
5
|
+
|
6
|
+
module ClosureTree
|
7
|
+
module Generators
|
8
|
+
class MigrationGeneratorTest < Rails::Generators::TestCase
|
9
|
+
tests MigrationGenerator
|
10
|
+
destination File.expand_path('../tmp', __dir__)
|
11
|
+
setup :prepare_destination
|
12
|
+
|
13
|
+
def test_generator_output
|
14
|
+
run_generator %w[tag]
|
15
|
+
migration_file = migration_file_name('db/migrate/create_tag_hierarchies.rb')
|
16
|
+
content = File.read(migration_file)
|
17
|
+
assert_match(/t.integer :ancestor_id, null: false/, content)
|
18
|
+
assert_match(/t.integer :descendant_id, null: false/, content)
|
19
|
+
assert_match(/t.integer :generations, null: false/, content)
|
20
|
+
assert_match(/add_index :tag_hierarchies/, content)
|
21
|
+
end
|
22
|
+
|
23
|
+
def test_generator_output_with_namespaced_model
|
24
|
+
run_generator %w[Namespace::Type]
|
25
|
+
migration_file = migration_file_name('db/migrate/create_namespace_type_hierarchies.rb')
|
26
|
+
content = File.read(migration_file)
|
27
|
+
assert_match(/t.integer :ancestor_id, null: false/, content)
|
28
|
+
assert_match(/t.integer :descendant_id, null: false/, content)
|
29
|
+
assert_match(/t.integer :generations, null: false/, content)
|
30
|
+
assert_match(/add_index :namespace_type_hierarchies/, content)
|
31
|
+
end
|
32
|
+
|
33
|
+
def test_generator_output_with_namespaced_model_with_slash
|
34
|
+
run_generator %w[namespace/type]
|
35
|
+
migration_file = migration_file_name('db/migrate/create_namespace_type_hierarchies.rb')
|
36
|
+
content = File.read(migration_file)
|
37
|
+
assert_match(/t.integer :ancestor_id, null: false/, content)
|
38
|
+
assert_match(/t.integer :descendant_id, null: false/, content)
|
39
|
+
assert_match(/t.integer :generations, null: false/, content)
|
40
|
+
assert_match(/add_index :namespace_type_hierarchies/, content)
|
41
|
+
end
|
42
|
+
|
43
|
+
def test_should_run_all_tasks_in_generator_without_errors
|
44
|
+
gen = generator %w[tag]
|
45
|
+
capture_io { gen.invoke_all }
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
|
3
|
+
class HasClosureTreeRootTest < ActiveSupport::TestCase
|
4
|
+
setup do
|
5
|
+
ENV['FLOCK_DIR'] = Dir.mktmpdir
|
6
|
+
end
|
7
|
+
|
8
|
+
teardown do
|
9
|
+
FileUtils.remove_entry_secure ENV['FLOCK_DIR']
|
10
|
+
end
|
11
|
+
def create_tree(group)
|
12
|
+
@ct1 = ContractType.create!(name: "Type1")
|
13
|
+
@ct2 = ContractType.create!(name: "Type2")
|
14
|
+
@user1 = User.create!(email: "1@example.com", group_id: group.id)
|
15
|
+
@user2 = User.create!(email: "2@example.com", group_id: group.id)
|
16
|
+
@user3 = User.create!(email: "3@example.com", group_id: group.id)
|
17
|
+
@user4 = User.create!(email: "4@example.com", group_id: group.id)
|
18
|
+
@user5 = User.create!(email: "5@example.com", group_id: group.id)
|
19
|
+
@user6 = User.create!(email: "6@example.com", group_id: group.id)
|
20
|
+
|
21
|
+
# The tree (contract types in parens)
|
22
|
+
#
|
23
|
+
# U1(1)
|
24
|
+
# / \
|
25
|
+
# U2(1) U3(1&2)
|
26
|
+
# / / \
|
27
|
+
# U4(2) U5(1) U6(2)
|
28
|
+
|
29
|
+
@user1.children << @user2
|
30
|
+
@user1.children << @user3
|
31
|
+
@user2.children << @user4
|
32
|
+
@user3.children << @user5
|
33
|
+
@user3.children << @user6
|
34
|
+
|
35
|
+
@user1.contracts.create!(title: "Contract 1", contract_type: @ct1)
|
36
|
+
@user2.contracts.create!(title: "Contract 2", contract_type: @ct1)
|
37
|
+
@user3.contracts.create!(title: "Contract 3", contract_type: @ct1)
|
38
|
+
@user3.contracts.create!(title: "Contract 4", contract_type: @ct2)
|
39
|
+
@user4.contracts.create!(title: "Contract 5", contract_type: @ct2)
|
40
|
+
@user5.contracts.create!(title: "Contract 6", contract_type: @ct1)
|
41
|
+
@user6.contracts.create!(title: "Contract 7", contract_type: @ct2)
|
42
|
+
end
|
43
|
+
|
44
|
+
test "loads all nodes in a constant number of queries" do
|
45
|
+
group = Group.create!(name: "TheGrouping")
|
46
|
+
create_tree(group)
|
47
|
+
reloaded_group = group.reload
|
48
|
+
exceed_query_limit(2) do
|
49
|
+
root = reloaded_group.root_user_including_tree
|
50
|
+
assert_equal "2@example.com", root.children[0].email
|
51
|
+
assert_equal "3@example.com", root.children[0].parent.children[1].email
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
test "loads all nodes plus single association in a constant number of queries" do
|
56
|
+
group = Group.create!(name: "TheGrouping")
|
57
|
+
create_tree(group)
|
58
|
+
reloaded_group = group.reload
|
59
|
+
exceed_query_limit(3) do
|
60
|
+
root = reloaded_group.root_user_including_tree(:contracts)
|
61
|
+
assert_equal "2@example.com", root.children[0].email
|
62
|
+
assert_equal "3@example.com", root.children[0].parent.children[1].email
|
63
|
+
assert_equal "Contract 7", root.children[0].children[0].contracts[0].user.parent.parent.children[1].children[1].contracts[0].title
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
test "loads all nodes and associations in a constant number of queries" do
|
68
|
+
group = Group.create!(name: "TheGrouping")
|
69
|
+
create_tree(group)
|
70
|
+
reloaded_group = group.reload
|
71
|
+
exceed_query_limit(4) do
|
72
|
+
root = reloaded_group.root_user_including_tree(contracts: :contract_type)
|
73
|
+
assert_equal "2@example.com", root.children[0].email
|
74
|
+
assert_equal "3@example.com", root.children[0].parent.children[1].email
|
75
|
+
assert_equal %w[Type1 Type2], root.children[1].contracts.map(&:contract_type).map(&:name)
|
76
|
+
assert_equal "Type1", root.children[1].children[0].contracts[0].contract_type.name
|
77
|
+
assert_equal "Type2", root.children[0].children[0].contracts[0].user.parent.parent.children[1].children[1].contracts[0].contract_type.name
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'test_helper'
|
4
|
+
|
5
|
+
describe ClosureTree::HierarchyMaintenance do
|
6
|
+
describe '.rebuild!' do
|
7
|
+
it 'rebuild tree' do
|
8
|
+
20.times do |counter|
|
9
|
+
Metal.create(value: "Nitro-#{counter}", parent: Metal.all.sample)
|
10
|
+
end
|
11
|
+
hierarchy_count = MetalHierarchy.count
|
12
|
+
assert_operator hierarchy_count, :>, (20 * 2) - 1 # shallowest-possible case, where all children use the first root
|
13
|
+
MetalHierarchy.delete_all
|
14
|
+
Metal.rebuild!
|
15
|
+
assert_equal MetalHierarchy.count, hierarchy_count
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
describe '.cleanup!' do
|
20
|
+
before do
|
21
|
+
@parent = Metal.create(value: 'parent metal')
|
22
|
+
@child = Metal.create(value: 'child metal', parent: @parent)
|
23
|
+
MetalHierarchy.delete_all
|
24
|
+
Metal.rebuild!
|
25
|
+
end
|
26
|
+
|
27
|
+
describe 'when an element is deleted' do
|
28
|
+
it 'should delete the child hierarchies' do
|
29
|
+
@child.delete
|
30
|
+
|
31
|
+
Metal.cleanup!
|
32
|
+
|
33
|
+
assert_empty MetalHierarchy.where(descendant_id: @child.id)
|
34
|
+
assert_empty MetalHierarchy.where(ancestor_id: @child.id)
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'should not delete the parent hierarchies' do
|
38
|
+
@child.delete
|
39
|
+
Metal.cleanup!
|
40
|
+
assert_equal 1, MetalHierarchy.where(ancestor_id: @parent.id).size
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'should not delete other hierarchies' do
|
44
|
+
other_parent = Metal.create(value: 'other parent metal')
|
45
|
+
other_child = Metal.create(value: 'other child metal', parent: other_parent)
|
46
|
+
Metal.rebuild!
|
47
|
+
|
48
|
+
@child.delete
|
49
|
+
Metal.cleanup!
|
50
|
+
|
51
|
+
assert_equal 2, MetalHierarchy.where(ancestor_id: other_parent.id).size
|
52
|
+
assert_equal 2, MetalHierarchy.where(descendant_id: other_child.id).size
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|