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
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ describe Metal do
6
+ describe '#find_or_create_by_path' do
7
+ def assert_correctness(grandchild)
8
+ assert(Metal, grandchild)
9
+ assert_equal 'slag', grandchild.description
10
+ child = grandchild.parent
11
+ assert(Unobtanium, child)
12
+ assert_equal 'frames', child.description
13
+ assert_equal 'child', child.value
14
+ parent = child.parent
15
+ assert(Adamantium, parent)
16
+ assert_equal 'claws', parent.description
17
+ assert_equal 'parent', parent.value
18
+ end
19
+
20
+ let(:attr_path) do
21
+ [
22
+ { value: 'parent', description: 'claws', metal_type: 'Adamantium' },
23
+ { value: 'child', description: 'frames', metal_type: 'Unobtanium' },
24
+ { value: 'grandchild', description: 'slag', metal_type: 'Metal' }
25
+ ]
26
+ end
27
+
28
+ if false
29
+ before do
30
+ # ensure the correct root is used in find_or_create_by_path:
31
+ [Metal, Adamantium, Unobtanium].each do |metal|
32
+ metal.find_or_create_by_path(%w[parent child grandchild])
33
+ end
34
+ end
35
+ end
36
+
37
+ it 'creates children from the proper root' do
38
+ assert_correctness(Metal.find_or_create_by_path(attr_path))
39
+ end
40
+
41
+ it 'supports STI from the base class' do
42
+ assert_correctness(Metal.find_or_create_by_path(attr_path))
43
+ end
44
+
45
+ it 'supports STI from a subclass' do
46
+ parent = Adamantium.create!(value: 'parent', description: 'claws')
47
+ assert_correctness(parent.find_or_create_by_path(attr_path.last(2)))
48
+ end
49
+
50
+ it 'maintains the current STI subclass if attributes are not specified' do
51
+ leaf = Unobtanium.find_or_create_by_path(%w[a b c d])
52
+ assert(Unobtanium, leaf)
53
+ assert_equal %w[c b a], leaf.ancestors.map(&:value)
54
+ leaf.ancestors.each do |anc|
55
+ assert(Unobtanium, anc)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ describe '#_ct' do
6
+ it 'should delegate to the Support instance on the class' do
7
+ assert_equal Tag._ct, Tag.new._ct
8
+ end
9
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ describe Namespace::Type do
6
+ describe 'class injection' do
7
+ it 'should build hierarchy classname correctly' do
8
+ assert_equal 'Namespace::TypeHierarchy', Namespace::Type.hierarchy_class.to_s
9
+ assert_equal 'Namespace::TypeHierarchy', Namespace::Type._ct.hierarchy_class_name
10
+ assert_equal 'TypeHierarchy', Namespace::Type._ct.short_hierarchy_class_name
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # We don't need to run the expensive parallel tests for every combination of prefix/suffix.
6
+ # Those affect SQL generation, not parallelism.
7
+ # SQLite doesn't support concurrency reliably, either.
8
+ def run_parallel_tests?
9
+ !sqlite? &&
10
+ ActiveRecord::Base.table_name_prefix.empty? &&
11
+ ActiveRecord::Base.table_name_suffix.empty?
12
+ end
13
+
14
+ def max_threads
15
+ 5
16
+ end
17
+
18
+ class WorkerBase
19
+ extend Forwardable
20
+ attr_reader :name
21
+
22
+ def_delegators :@thread, :join, :wakeup, :status, :to_s
23
+
24
+ def log(msg)
25
+ puts("#{Thread.current}: #{msg}") if ENV["VERBOSE"]
26
+ end
27
+
28
+ def initialize(target, name)
29
+ @target = target
30
+ @name = name
31
+ @thread = Thread.new do
32
+ ActiveRecord::Base.connection_pool.with_connection { before_work } if respond_to? :before_work
33
+ log "going to sleep..."
34
+ sleep
35
+ log "woke up..."
36
+ ActiveRecord::Base.connection_pool.with_connection { work }
37
+ log "done."
38
+ end
39
+ end
40
+ end
41
+
42
+ class FindOrCreateWorker < WorkerBase
43
+ def work
44
+ path = [name, :a, :b, :c]
45
+ log "making #{path}..."
46
+ t = (@target || Tag).find_or_create_by_path(path)
47
+ log "made #{t.id}, #{t.ancestry_path}"
48
+ end
49
+ end
50
+
51
+ class SiblingPrependerWorker < WorkerBase
52
+ def before_work
53
+ @target.reload
54
+ @sibling = Label.new(name: SecureRandom.hex(10))
55
+ end
56
+
57
+ def work
58
+ @target.prepend_sibling @sibling
59
+ end
60
+ end
61
+
62
+ describe "Concurrent creation" do
63
+ before do
64
+ @target = nil
65
+ @iterations = 5
66
+ end
67
+
68
+ def log(msg)
69
+ puts(msg) if ENV["VERBOSE"]
70
+ end
71
+
72
+ def run_workers(worker_class = FindOrCreateWorker)
73
+ @names = @iterations.times.map { |iter| "iteration ##{iter}" }
74
+ @names.each do |name|
75
+ workers = max_threads.times.map { worker_class.new(@target, name) }
76
+ # Wait for all the threads to get ready:
77
+ while true
78
+ unready_workers = workers.select { |ea| ea.status != "sleep" }
79
+ if unready_workers.empty?
80
+ break
81
+ else
82
+ log "Not ready to wakeup: #{unready_workers.map { |ea| [ea.to_s, ea.status] }}"
83
+ sleep(0.1)
84
+ end
85
+ end
86
+ sleep(0.25)
87
+ # OK, GO!
88
+ log "Calling .wakeup on all workers..."
89
+ workers.each(&:wakeup)
90
+ sleep(0.25)
91
+ # Then wait for them to finish:
92
+ log "Calling .join on all workers..."
93
+ workers.each(&:join)
94
+ end
95
+ # Ensure we're still connected:
96
+ ActiveRecord::Base.connection_pool.connection
97
+ end
98
+
99
+ it "will not create dupes from class methods" do
100
+ skip("unsupported") unless run_parallel_tests?
101
+
102
+ run_workers
103
+ assert_equal @names.sort, Tag.roots.collect { |ea| ea.name }.sort
104
+ # No dupe children:
105
+ %w[a b c].each do |ea|
106
+ assert_equal @iterations, Tag.where(name: ea).size
107
+ end
108
+ end
109
+
110
+ it "will not create dupes from instance methods" do
111
+ skip("unsupported") unless run_parallel_tests?
112
+
113
+ @target = Tag.create!(name: "root")
114
+ run_workers
115
+ assert_equal @names.sort, @target.reload.children.collect { |ea| ea.name }.sort
116
+ assert_equal @iterations, Tag.where(name: @names).size
117
+ %w[a b c].each do |ea|
118
+ assert_equal @iterations, Tag.where(name: ea).size
119
+ end
120
+ end
121
+
122
+ it "creates dupe roots without advisory locks" do
123
+ skip("unsupported") unless run_parallel_tests?
124
+
125
+ # disable with_advisory_lock:
126
+ Tag.stub(:with_advisory_lock, ->(_lock_name, &block) { block.call }) do
127
+ run_workers
128
+ # duplication from at least one iteration:
129
+ assert Tag.where(name: @names).size > @iterations
130
+ end
131
+ end
132
+
133
+ it "fails to deadlock while simultaneously deleting items from the same hierarchy" do
134
+ skip("unsupported") unless run_parallel_tests?
135
+
136
+ target = User.find_or_create_by_path((1..200).to_a.map { |ea| ea.to_s })
137
+ emails = target.self_and_ancestors.to_a.map(&:email).shuffle
138
+ Parallel.map(emails, in_threads: max_threads) do |email|
139
+ ActiveRecord::Base.connection_pool.with_connection do
140
+ User.transaction do
141
+ log "Destroying #{email}..."
142
+ User.where(email: email).destroy_all
143
+ end
144
+ end
145
+ end
146
+ User.connection.reconnect!
147
+ assert User.all.empty?
148
+ end
149
+
150
+ it "fails to deadlock from prepending siblings" do
151
+ skip("unsupported") unless run_parallel_tests?
152
+
153
+ @target = Label.find_or_create_by_path %w[root parent]
154
+ run_workers(SiblingPrependerWorker)
155
+ children = Label.roots
156
+ uniq_order_values = children.collect { |ea| ea.order_value }.uniq
157
+ assert_equal uniq_order_values.size, children.size
158
+
159
+ # The only non-root node should be "root":
160
+ assert_equal([@target.parent], Label.all.select { |ea| ea.root? })
161
+ end
162
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ describe 'Configuration' do
6
+ it 'returns connection to the pool after has_closure_tree setup' do
7
+ class TypeDuplicate < ActiveRecord::Base
8
+ self.table_name = "namespace_type#{table_name_suffix}"
9
+ has_closure_tree
10
+ end
11
+
12
+ refute ActiveRecord::Base.connection_pool.active_connection?
13
+ # +false+ in AR 4, +nil+ in AR 5
14
+ end
15
+
16
+ it 'returns connection to the pool after has_closure_tree setup with order' do
17
+ class MetalDuplicate < ActiveRecord::Base
18
+ self.table_name = "#{table_name_prefix}metal#{table_name_suffix}"
19
+ has_closure_tree order: 'sort_order', name_column: 'value'
20
+ end
21
+
22
+ refute ActiveRecord::Base.connection_pool.active_connection?
23
+ end
24
+
25
+ it 'returns connection to the pool after has_closure_tree_root setup' do
26
+ class GroupDuplicate < ActiveRecord::Base
27
+ self.table_name = "#{table_name_prefix}group#{table_name_suffix}"
28
+ has_closure_tree_root :root_user
29
+ end
30
+
31
+ refute ActiveRecord::Base.connection_pool.active_connection?
32
+ end
33
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ describe ClosureTree::Support do
6
+ let(:sut) { Tag._ct }
7
+
8
+ it 'passes through table names without prefix and suffix' do
9
+ expected = 'some_random_table_name'
10
+ assert_equal expected, sut.remove_prefix_and_suffix(expected)
11
+ end
12
+
13
+ it 'extracts through table name with prefix and suffix' do
14
+ expected = 'some_random_table_name'
15
+ tn = ActiveRecord::Base.table_name_prefix + expected + ActiveRecord::Base.table_name_suffix
16
+ assert_equal expected, sut.remove_prefix_and_suffix(tn)
17
+ end
18
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+ require 'support/tag_examples'
5
+
6
+ describe Tag do
7
+ include TagExamples
8
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ describe "empty db" do
6
+ describe "empty db" do
7
+ it "should return no entities" do
8
+ assert User.roots.empty?
9
+ assert User.leaves.empty?
10
+ end
11
+ end
12
+
13
+ describe "1 user db" do
14
+ it "should return the only entity as a root and leaf" do
15
+ a = User.create!(email: "me@domain.com")
16
+ assert_equal [a], User.roots
17
+ assert_equal [a], User.leaves
18
+ end
19
+ end
20
+
21
+ describe "2 user db" do
22
+ it "should return a simple root and leaf" do
23
+ root = User.create!(email: "first@t.co")
24
+ leaf = root.children.create!(email: "second@t.co")
25
+ assert_equal [root], User.roots
26
+ assert_equal [leaf], User.leaves
27
+ end
28
+ end
29
+
30
+ describe "3 User collection.create db" do
31
+ before do
32
+ @root = User.create! email: "poppy@t.co"
33
+ @mid = @root.children.create! email: "matt@t.co"
34
+ @leaf = @mid.children.create! email: "james@t.co"
35
+ @root_id = @root.id
36
+ end
37
+
38
+ it "should create all Users" do
39
+ assert_equal [@root, @mid, @leaf], User.all.to_a.sort
40
+ end
41
+
42
+ it "orders self_and_ancestor_ids nearest generation first" do
43
+ assert_equal [@leaf.id, @mid.id, @root.id], @leaf.self_and_ancestor_ids
44
+ end
45
+
46
+ it "orders self_and_descendant_ids nearest generation first" do
47
+ assert_equal [@root.id, @mid.id, @leaf.id], @root.self_and_descendant_ids
48
+ end
49
+
50
+ it "should have children" do
51
+ assert_equal [@mid], @root.children.to_a
52
+ assert_equal [@leaf], @mid.children.to_a
53
+ assert_equal [], @leaf.children.to_a
54
+ end
55
+
56
+ it "roots should have children" do
57
+ assert_equal [@mid], User.roots.first.children.to_a
58
+ end
59
+
60
+ it "should return a root and leaf without middle User" do
61
+ assert_equal [@root], User.roots.to_a
62
+ assert_equal [@leaf], User.leaves.to_a
63
+ end
64
+
65
+ it "should delete leaves" do
66
+ User.leaves.destroy_all
67
+ assert_equal [@root], User.roots.to_a # untouched
68
+ assert_equal [@mid], User.leaves.to_a
69
+ end
70
+
71
+ it "should delete roots and maintain hierarchies" do
72
+ User.roots.destroy_all
73
+ assert_mid_and_leaf_remain
74
+ end
75
+
76
+ it "should root all children" do
77
+ @root.destroy
78
+ assert_mid_and_leaf_remain
79
+ end
80
+
81
+ def assert_mid_and_leaf_remain
82
+ assert ReferralHierarchy.where(ancestor_id: @root_id).empty?
83
+ assert ReferralHierarchy.where(descendant_id: @root_id).empty?
84
+ assert_equal %w[matt@t.co], @mid.ancestry_path
85
+ assert_equal %w[matt@t.co james@t.co], @leaf.ancestry_path
86
+ assert_equal [@mid, @leaf].sort, @mid.self_and_descendants.to_a.sort
87
+ assert_equal [@mid], User.roots
88
+ assert_equal [@leaf], User.leaves
89
+ end
90
+ end
91
+
92
+ it "supports users with contracts" do
93
+ u = User.find_or_create_by_path(%w[a@t.co b@t.co c@t.co])
94
+ assert_equal [], u.descendant_ids
95
+ assert_equal [u.parent.id, u.root.id], u.ancestor_ids
96
+ assert_equal [u.id, u.parent.id, u.root.id], u.self_and_ancestor_ids
97
+ assert_equal [u.parent.id, u.id], u.root.descendant_ids
98
+ assert_equal [], u.root.ancestor_ids
99
+ assert_equal [u.root.id], u.root.self_and_ancestor_ids
100
+ c1 = u.contracts.create!
101
+ c2 = u.parent.contracts.create!
102
+ assert_equal [c1, c2].sort, u.root.indirect_contracts.to_a.sort
103
+ end
104
+
105
+ it "supports << on shallow unsaved hierarchies" do
106
+ a = User.new(email: "a")
107
+ b = User.new(email: "b")
108
+ a.children << b
109
+ a.save
110
+ assert_equal [a], User.roots
111
+ assert_equal [b], User.leaves
112
+ assert_equal %w[a b], b.ancestry_path
113
+ end
114
+
115
+ it "supports << on deep unsaved hierarchies" do
116
+ a = User.new(email: "a")
117
+ b1 = User.new(email: "b1")
118
+ a.children << b1
119
+ b2 = User.new(email: "b2")
120
+ a.children << b2
121
+ c1 = User.new(email: "c1")
122
+ b2.children << c1
123
+ c2 = User.new(email: "c2")
124
+ b2.children << c2
125
+ d = User.new(email: "d")
126
+ c2.children << d
127
+
128
+ a.save
129
+ assert_equal [a], User.roots.to_a
130
+ assert_equal [b1, c1, d].sort, User.leaves.to_a.sort
131
+ assert_equal %w[a b2 c2 d], d.ancestry_path
132
+ end
133
+
134
+ it "supports siblings" do
135
+ refute User._ct.order_option?
136
+ a = User.create(email: "a")
137
+ b1 = a.children.create(email: "b1")
138
+ b2 = a.children.create(email: "b2")
139
+ b3 = a.children.create(email: "b3")
140
+ assert a.siblings.empty?
141
+ assert_equal [b2, b3].sort, b1.siblings.to_a.sort
142
+ end
143
+
144
+ describe "when a user is not yet saved" do
145
+ it "supports siblings" do
146
+ refute User._ct.order_option?
147
+ a = User.create(email: "a")
148
+ b1 = a.children.new(email: "b1")
149
+ b2 = a.children.create(email: "b2")
150
+ b3 = a.children.create(email: "b3")
151
+ assert a.siblings.empty?
152
+ assert_equal [b2, b3].sort, b1.siblings.to_a.sort
153
+ end
154
+ end
155
+
156
+ it "properly nullifies descendents" do
157
+ c = User.find_or_create_by_path %w[a b c]
158
+ b = c.parent
159
+ c.root.destroy
160
+ assert b.reload.root?
161
+ assert_equal [c.id], b.child_ids
162
+ end
163
+
164
+ describe "roots" do
165
+ it "works on models without ordering" do
166
+ expected = ("a".."z").to_a
167
+ expected.shuffle.each do |ea|
168
+ User.create! do |u|
169
+ u.email = ea
170
+ end
171
+ end
172
+ assert_equal(expected, User.roots.collect { |ea| ea.email }.sort)
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+ require 'support/tag_examples'
5
+
6
+ describe UUIDTag do
7
+ include TagExamples
8
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # https://stackoverflow.com/a/43810063/1683557
4
+
5
+ module QueryCounter
6
+ def sql_queries(&block)
7
+ queries = []
8
+ counter = lambda { |*, payload|
9
+ queries << payload.fetch(:sql) unless %w[CACHE SCHEMA].include?(payload.fetch(:name))
10
+ }
11
+
12
+ ActiveSupport::Notifications.subscribed(counter, "sql.active_record", &block)
13
+
14
+ queries
15
+ end
16
+
17
+ def assert_database_queries_count(expected, &block)
18
+ queries = sql_queries(&block)
19
+ assert_equal(
20
+ expected,
21
+ queries.count,
22
+ "Expected #{expected} queries, but found #{queries.count}:\n#{queries.join("\n")}"
23
+ )
24
+ end
25
+ end