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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +30 -56
  3. data/.github/workflows/ci_jruby.yml +68 -0
  4. data/.github/workflows/ci_truffleruby.yml +71 -0
  5. data/.github/workflows/release.yml +17 -0
  6. data/.gitignore +1 -1
  7. data/.release-please-manifest.json +1 -0
  8. data/.tool-versions +1 -0
  9. data/Appraisals +9 -53
  10. data/CHANGELOG.md +5 -0
  11. data/Gemfile +2 -3
  12. data/README.md +21 -9
  13. data/Rakefile +11 -16
  14. data/closure_tree.gemspec +16 -9
  15. data/lib/closure_tree/active_record_support.rb +3 -14
  16. data/lib/closure_tree/digraphs.rb +1 -1
  17. data/lib/closure_tree/finders.rb +1 -1
  18. data/lib/closure_tree/hash_tree.rb +1 -1
  19. data/lib/closure_tree/hierarchy_maintenance.rb +3 -6
  20. data/lib/closure_tree/model.rb +3 -3
  21. data/lib/closure_tree/numeric_deterministic_ordering.rb +3 -8
  22. data/lib/closure_tree/support.rb +3 -7
  23. data/lib/closure_tree/version.rb +1 -1
  24. data/lib/generators/closure_tree/migration_generator.rb +1 -4
  25. data/release-please-config.json +4 -0
  26. data/test/closure_tree/cache_invalidation_test.rb +36 -0
  27. data/test/closure_tree/cuisine_type_test.rb +42 -0
  28. data/test/closure_tree/generator_test.rb +49 -0
  29. data/test/closure_tree/has_closure_tree_root_test.rb +80 -0
  30. data/test/closure_tree/hierarchy_maintenance_test.rb +56 -0
  31. data/test/closure_tree/label_test.rb +674 -0
  32. data/test/closure_tree/metal_test.rb +59 -0
  33. data/test/closure_tree/model_test.rb +9 -0
  34. data/test/closure_tree/namespace_type_test.rb +13 -0
  35. data/test/closure_tree/parallel_test.rb +162 -0
  36. data/test/closure_tree/pool_test.rb +33 -0
  37. data/test/closure_tree/support_test.rb +18 -0
  38. data/test/closure_tree/tag_test.rb +8 -0
  39. data/test/closure_tree/user_test.rb +175 -0
  40. data/test/closure_tree/uuid_tag_test.rb +8 -0
  41. data/test/support/query_counter.rb +25 -0
  42. data/test/support/tag_examples.rb +923 -0
  43. data/test/test_helper.rb +99 -0
  44. metadata +52 -21
  45. data/_config.yml +0 -1
  46. data/tests.sh +0 -11
@@ -6,12 +6,12 @@ module ClosureTree
6
6
 
7
7
  included do
8
8
 
9
- belongs_to :parent, nil, **_ct.belongs_to_with_optional_option(
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
- ancestors.size
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
- as_5_1 = ActiveSupport.version >= Gem::Version.new('5.1.0')
14
- change_method = as_5_1 ? :saved_change_to_attribute? : :attribute_changed?
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
- module ClassMethods
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)
@@ -32,7 +32,7 @@ module ClosureTree
32
32
  end
33
33
 
34
34
  def hierarchy_class_for_model
35
- parent_class = ActiveSupport::VERSION::MAJOR >= 6 ? model_class.module_parent : model_class.parent
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
- ActiveRecord::Base.table_name_prefix + tablename + ActiveRecord::Base.table_name_suffix
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) }]
@@ -1,3 +1,3 @@
1
1
  module ClosureTree
2
- VERSION = Gem::Version.new('7.4.0')
2
+ VERSION = Gem::Version.new('8.0.0')
3
3
  end
@@ -43,10 +43,7 @@ module ClosureTree
43
43
  end
44
44
 
45
45
  def migration_version
46
- major = ActiveRecord::VERSION::MAJOR
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,4 @@
1
+ {
2
+ "release-type": "ruby",
3
+ "package-name": "closure_tree"
4
+ }
@@ -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