closure_tree 4.6.3 → 5.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/.travis.yml +3 -0
- data/Appraisals +8 -8
- data/CHANGELOG.md +29 -14
- data/README.md +30 -20
- data/closure_tree.gemspec +8 -9
- data/lib/closure_tree/acts_as_tree.rb +3 -5
- data/lib/closure_tree/finders.rb +36 -48
- data/lib/closure_tree/model.rb +1 -0
- data/lib/closure_tree/support.rb +55 -2
- data/lib/closure_tree/support_attributes.rb +3 -8
- data/lib/closure_tree/support_flags.rb +2 -3
- data/lib/closure_tree/version.rb +1 -1
- data/spec/db/database.yml +2 -2
- data/spec/db/models.rb +7 -1
- data/spec/db/schema.rb +1 -0
- data/spec/hierarchy_maintenance_spec.rb +1 -0
- data/spec/label_spec.rb +48 -36
- data/spec/metal_spec.rb +50 -4
- data/spec/parallel_spec.rb +66 -49
- data/spec/spec_helper.rb +3 -0
- data/spec/support/database.rb +13 -11
- data/spec/tag_examples.rb +137 -102
- data/tests.sh +2 -4
- metadata +18 -20
- data/spec/support/helpers.rb +0 -8
@@ -3,15 +3,10 @@ module ClosureTree
|
|
3
3
|
module SupportAttributes
|
4
4
|
|
5
5
|
extend Forwardable
|
6
|
-
def_delegators :model_class, :connection, :transaction, :table_name
|
6
|
+
def_delegators :model_class, :connection, :transaction, :table_name, :base_class, :inheritance_column, :column_names
|
7
7
|
|
8
|
-
|
9
|
-
|
10
|
-
options[:base_class]
|
11
|
-
end
|
12
|
-
|
13
|
-
def attribute_names
|
14
|
-
@attribute_names ||= model_class.new.attributes.keys - model_class.protected_attributes.to_a
|
8
|
+
def advisory_lock_name
|
9
|
+
"ClosureTree::#{base_class.name}"
|
15
10
|
end
|
16
11
|
|
17
12
|
def quoted_table_name
|
@@ -27,13 +27,12 @@ module ClosureTree
|
|
27
27
|
model_class != model_class.base_class
|
28
28
|
end
|
29
29
|
|
30
|
-
def
|
31
|
-
|
30
|
+
def has_inheritance_column?(hash = columns_hash)
|
31
|
+
hash.with_indifferent_access.include?(model_class.inheritance_column)
|
32
32
|
end
|
33
33
|
|
34
34
|
def has_name?
|
35
35
|
model_class.new.attributes.include? options[:name_column]
|
36
36
|
end
|
37
|
-
|
38
37
|
end
|
39
38
|
end
|
data/lib/closure_tree/version.rb
CHANGED
data/spec/db/database.yml
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
common: &common
|
2
|
-
database:
|
2
|
+
database: <%= db_name %>
|
3
3
|
host: localhost
|
4
4
|
pool: 50
|
5
5
|
timeout: 5000
|
@@ -7,8 +7,8 @@ common: &common
|
|
7
7
|
min_messages: ERROR
|
8
8
|
|
9
9
|
sqlite:
|
10
|
+
<<: *common
|
10
11
|
adapter: <%= "jdbc" if defined? JRUBY_VERSION %>sqlite3
|
11
|
-
database: ':memory:'
|
12
12
|
|
13
13
|
postgresql:
|
14
14
|
<<: *common
|
data/spec/db/models.rb
CHANGED
@@ -98,10 +98,16 @@ end
|
|
98
98
|
|
99
99
|
class Metal < ActiveRecord::Base
|
100
100
|
self.table_name = "#{table_name_prefix}metal#{table_name_suffix}"
|
101
|
-
acts_as_tree :
|
101
|
+
acts_as_tree order: 'sort_order', name_column: 'value'
|
102
102
|
self.inheritance_column = 'metal_type'
|
103
103
|
end
|
104
104
|
|
105
|
+
class Adamantium < Metal
|
106
|
+
end
|
107
|
+
|
108
|
+
class Unobtanium < Metal
|
109
|
+
end
|
110
|
+
|
105
111
|
class MenuItem < ActiveRecord::Base
|
106
112
|
acts_as_tree(touch: true, with_advisory_lock: false)
|
107
113
|
end
|
data/spec/db/schema.rb
CHANGED
@@ -7,6 +7,7 @@ describe ClosureTree::HierarchyMaintenance do
|
|
7
7
|
Metal.create(:value => "Nitro-#{counter}", parent: Metal.all.sample)
|
8
8
|
end
|
9
9
|
hierarchy_count = MetalHierarchy.count
|
10
|
+
expect(hierarchy_count).to be > (20*2)-1 # shallowest-possible case, where all children use the first root
|
10
11
|
MetalHierarchy.delete_all
|
11
12
|
Metal.rebuild!
|
12
13
|
expect(MetalHierarchy.count).to eq(hierarchy_count)
|
data/spec/label_spec.rb
CHANGED
@@ -42,7 +42,6 @@ def create_preorder_tree(suffix = "", &block)
|
|
42
42
|
end
|
43
43
|
|
44
44
|
describe Label do
|
45
|
-
|
46
45
|
context "destruction" do
|
47
46
|
it "properly destroys descendents created with find_or_create_by_path" do
|
48
47
|
c = Label.find_or_create_by_path %w(a b c)
|
@@ -54,10 +53,8 @@ describe Label do
|
|
54
53
|
|
55
54
|
it "properly destroys descendents created with add_child" do
|
56
55
|
a = Label.create(name: 'a')
|
57
|
-
b = Label.new(name: 'b')
|
58
|
-
|
59
|
-
c = Label.new(name: 'c')
|
60
|
-
b.add_child c
|
56
|
+
b = a.add_child Label.new(name: 'b')
|
57
|
+
c = b.add_child Label.new(name: 'c')
|
61
58
|
a.destroy
|
62
59
|
expect(Label.exists?(a)).to be_falsey
|
63
60
|
expect(Label.exists?(b)).to be_falsey
|
@@ -292,36 +289,51 @@ describe Label do
|
|
292
289
|
end
|
293
290
|
end
|
294
291
|
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
292
|
+
describe 'code in the readme' do
|
293
|
+
it 'creates STI label hierarchies' do
|
294
|
+
child = Label.find_or_create_by_path([
|
295
|
+
{type: 'DateLabel', name: '2014'},
|
296
|
+
{type: 'DateLabel', name: 'August'},
|
297
|
+
{type: 'DateLabel', name: '5'},
|
298
|
+
{type: 'EventLabel', name: 'Visit the Getty Center'}
|
299
|
+
])
|
300
|
+
expect(child).to be_a(EventLabel)
|
301
|
+
expect(child.name).to eq('Visit the Getty Center')
|
302
|
+
expect(child.ancestors.map(&:name)).to eq(%w(5 August 2014))
|
303
|
+
expect(child.ancestors.map(&:class)).to eq([DateLabel, DateLabel, DateLabel])
|
304
|
+
end
|
305
|
+
|
306
|
+
it 'appends and prepends siblings' do
|
307
|
+
root = Label.create(name: 'root')
|
308
|
+
a = root.append_child(Label.new(name: 'a'))
|
309
|
+
b = Label.create(name: 'b')
|
310
|
+
c = Label.create(name: 'c')
|
311
|
+
|
312
|
+
a.append_sibling(b)
|
313
|
+
expect(a.self_and_siblings.collect(&:name)).to eq(%w(a b))
|
314
|
+
expect(root.reload.children.collect(&:name)).to eq(%w(a b))
|
315
|
+
expect(root.children.collect(&:order_value)).to eq([0, 1])
|
316
|
+
|
317
|
+
a.prepend_sibling(b)
|
318
|
+
expect(a.self_and_siblings.collect(&:name)).to eq(%w(b a))
|
319
|
+
expect(root.reload.children.collect(&:name)).to eq(%w(b a))
|
320
|
+
expect(root.children.collect(&:order_value)).to eq([0, 1])
|
321
|
+
|
322
|
+
a.append_sibling(c)
|
323
|
+
expect(a.self_and_siblings.collect(&:name)).to eq(%w(b a c))
|
324
|
+
expect(root.reload.children.collect(&:name)).to eq(%w(b a c))
|
325
|
+
expect(root.children.collect(&:order_value)).to eq([0, 1, 2])
|
326
|
+
|
327
|
+
# We need to reload b because it was updated by a.append_sibling(c)
|
328
|
+
b.reload.append_sibling(c)
|
329
|
+
expect(root.reload.children.collect(&:name)).to eq(%w(b c a))
|
330
|
+
expect(root.children.collect(&:order_value)).to eq([0, 1, 2])
|
331
|
+
|
332
|
+
# We need to reload a because it was updated by b.append_sibling(c)
|
333
|
+
d = a.reload.append_sibling(Label.new(:name => "d"))
|
334
|
+
expect(d.self_and_siblings.collect(&:name)).to eq(%w(b c a d))
|
335
|
+
expect(d.self_and_siblings.collect(&:order_value)).to eq([0, 1, 2, 3])
|
336
|
+
end
|
325
337
|
end
|
326
338
|
|
327
339
|
# https://github.com/mceachen/closure_tree/issues/84
|
@@ -511,5 +523,5 @@ describe Label do
|
|
511
523
|
expected += ('a'..'r').collect { |ea| "#{ea}1" }
|
512
524
|
expect(Label.roots_and_descendants_preordered.collect { |ea| ea.name }).to eq(expected)
|
513
525
|
end
|
514
|
-
end unless
|
526
|
+
end unless sqlite? # sqlite doesn't have a power function.
|
515
527
|
end
|
data/spec/metal_spec.rb
CHANGED
@@ -1,9 +1,55 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
3
|
describe Metal do
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
4
|
+
describe '#find_or_create_by_path' do
|
5
|
+
def assert_correctness(grandchild)
|
6
|
+
expect(grandchild).to be_a(Metal)
|
7
|
+
expect(grandchild.description).to eq('slag')
|
8
|
+
child = grandchild.parent
|
9
|
+
expect(child).to be_a(Unobtanium)
|
10
|
+
expect(child.description).to eq('frames')
|
11
|
+
expect(child.value).to eq('child')
|
12
|
+
parent = child.parent
|
13
|
+
expect(parent).to be_a(Adamantium)
|
14
|
+
expect(parent.description).to eq('claws')
|
15
|
+
expect(parent.value).to eq('parent')
|
16
|
+
end
|
17
|
+
|
18
|
+
let(:attr_path) do
|
19
|
+
[
|
20
|
+
{value: 'parent', description: 'claws', metal_type: 'Adamantium'},
|
21
|
+
{value: 'child', description: 'frames', metal_type: 'Unobtanium'},
|
22
|
+
{value: 'grandchild', description: 'slag', metal_type: 'Metal'}
|
23
|
+
]
|
24
|
+
end
|
25
|
+
|
26
|
+
before do
|
27
|
+
# ensure the correct root is used in find_or_create_by_path:
|
28
|
+
[Metal, Adamantium, Unobtanium].each do |metal|
|
29
|
+
metal.find_or_create_by_path(%w(parent child grandchild))
|
30
|
+
end
|
31
|
+
end if false
|
32
|
+
|
33
|
+
it 'creates children from the proper root' do
|
34
|
+
assert_correctness(Metal.find_or_create_by_path(attr_path))
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'supports STI from the base class' do
|
38
|
+
assert_correctness(Metal.find_or_create_by_path(attr_path))
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'supports STI from a subclass' do
|
42
|
+
parent = Adamantium.create!(value: 'parent', description: 'claws')
|
43
|
+
assert_correctness(parent.find_or_create_by_path(attr_path.last(2)))
|
44
|
+
end
|
45
|
+
|
46
|
+
it 'maintains the current STI subclass if attributes are not specified' do
|
47
|
+
leaf = Unobtanium.find_or_create_by_path(%w(a b c d))
|
48
|
+
expect(leaf).to be_a(Unobtanium)
|
49
|
+
expect(leaf.ancestors.map(&:value)).to eq(%w(c b a))
|
50
|
+
leaf.ancestors.each do |anc|
|
51
|
+
expect(anc).to be_a(Unobtanium)
|
52
|
+
end
|
53
|
+
end
|
8
54
|
end
|
9
55
|
end
|
data/spec/parallel_spec.rb
CHANGED
@@ -1,59 +1,75 @@
|
|
1
1
|
require 'spec_helper'
|
2
|
-
|
2
|
+
|
3
|
+
# We don't need to run the expensive parallel tests for every combination of prefix/suffix.
|
4
|
+
# Those affect SQL generation, not parallelism
|
5
|
+
def run_parallel_tests?
|
6
|
+
ActiveRecord::Base.table_name_prefix.empty? &&
|
7
|
+
ActiveRecord::Base.table_name_suffix.empty?
|
8
|
+
end
|
9
|
+
|
10
|
+
def max_threads
|
11
|
+
5
|
12
|
+
end
|
3
13
|
|
4
14
|
class WorkerBase
|
5
|
-
|
15
|
+
extend Forwardable
|
16
|
+
attr_reader :name
|
17
|
+
def_delegators :@thread, :join, :wakeup, :status, :to_s
|
18
|
+
|
19
|
+
def initialize(target, name)
|
6
20
|
@target = target
|
21
|
+
@name = name
|
7
22
|
@thread = Thread.new do
|
8
|
-
ActiveRecord::Base.connection_pool.with_connection
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
23
|
+
ActiveRecord::Base.connection_pool.with_connection { before_work } if respond_to? :before_work
|
24
|
+
puts "#{Thread.current} going to sleep..."
|
25
|
+
sleep
|
26
|
+
puts "#{Thread.current} woke up..."
|
27
|
+
ActiveRecord::Base.connection_pool.with_connection { work }
|
28
|
+
puts "#{Thread.current} done..."
|
13
29
|
end
|
14
30
|
end
|
15
|
-
|
16
|
-
def before_work
|
17
|
-
end
|
18
|
-
|
19
|
-
def work(name)
|
20
|
-
raise
|
21
|
-
end
|
22
|
-
|
23
|
-
def join
|
24
|
-
@thread.join
|
25
|
-
end
|
26
31
|
end
|
27
32
|
|
28
33
|
class FindOrCreateWorker < WorkerBase
|
29
|
-
def
|
30
|
-
|
34
|
+
def work
|
35
|
+
path = [name, :a, :b, :c]
|
36
|
+
puts "#{Thread.current} making #{path}..."
|
37
|
+
t = (@target || Tag).find_or_create_by_path(path)
|
38
|
+
puts "#{Thread.current} made #{t.id}, #{t.ancestry_path}"
|
31
39
|
end
|
32
40
|
end
|
33
41
|
|
34
|
-
describe 'Concurrent creation'
|
35
|
-
|
42
|
+
describe 'Concurrent creation' do
|
36
43
|
before :each do
|
37
44
|
@target = nil
|
38
45
|
@iterations = 5
|
39
|
-
@threads = 10
|
40
46
|
end
|
41
47
|
|
42
48
|
def run_workers(worker_class = FindOrCreateWorker)
|
43
|
-
all_workers = []
|
44
49
|
@names = @iterations.times.map { |iter| "iteration ##{iter}" }
|
45
50
|
@names.each do |name|
|
46
|
-
|
47
|
-
|
48
|
-
|
51
|
+
workers = max_threads.times.map { worker_class.new(@target, name) }
|
52
|
+
# Wait for all the threads to get ready:
|
53
|
+
while true
|
54
|
+
unready_workers = workers.select { |ea| ea.status != 'sleep' }
|
55
|
+
if unready_workers.empty?
|
56
|
+
break
|
57
|
+
else
|
58
|
+
puts "Not ready to wakeup: #{unready_workers.map { |ea| [ea.to_s, ea.status] }}"
|
59
|
+
sleep(0.1)
|
60
|
+
end
|
49
61
|
end
|
62
|
+
sleep(0.25)
|
63
|
+
# OK, GO!
|
64
|
+
puts 'Calling .wakeup on all workers...'
|
65
|
+
workers.each(&:wakeup)
|
66
|
+
sleep(0.25)
|
67
|
+
# Then wait for them to finish:
|
68
|
+
puts 'Calling .join on all workers...'
|
50
69
|
workers.each(&:join)
|
51
|
-
all_workers += workers
|
52
|
-
puts name
|
53
70
|
end
|
54
71
|
# Ensure we're still connected:
|
55
72
|
ActiveRecord::Base.connection_pool.connection
|
56
|
-
all_workers
|
57
73
|
end
|
58
74
|
|
59
75
|
it 'will not create dupes from class methods' do
|
@@ -79,8 +95,9 @@ describe 'Concurrent creation', if: support_concurrency do
|
|
79
95
|
# disable with_advisory_lock:
|
80
96
|
allow(Tag).to receive(:with_advisory_lock) { |_lock_name, &block| block.call }
|
81
97
|
run_workers
|
98
|
+
# duplication from at least one iteration:
|
82
99
|
expect(Tag.where(name: @names).size).to be > @iterations
|
83
|
-
end
|
100
|
+
end unless sqlite? # sqlite throws errors from concurrent access
|
84
101
|
|
85
102
|
class SiblingPrependerWorker < WorkerBase
|
86
103
|
def before_work
|
@@ -88,11 +105,12 @@ describe 'Concurrent creation', if: support_concurrency do
|
|
88
105
|
@sibling = Label.new(name: SecureRandom.hex(10))
|
89
106
|
end
|
90
107
|
|
91
|
-
def
|
108
|
+
def work
|
92
109
|
@target.prepend_sibling @sibling
|
93
110
|
end
|
94
111
|
end
|
95
112
|
|
113
|
+
# TODO: this test should be rewritten to be proper producer-consumer code
|
96
114
|
xit 'fails to deadlock from parallel sibling churn' do
|
97
115
|
# target should be non-trivially long to maximize time spent in hierarchy maintenance
|
98
116
|
target = Tag.find_or_create_by_path(('a'..'z').to_a + ('A'..'Z').to_a)
|
@@ -102,7 +120,7 @@ describe 'Concurrent creation', if: support_concurrency do
|
|
102
120
|
children_to_delete = []
|
103
121
|
deleted_children = []
|
104
122
|
creator_threads = @workers.times.map do
|
105
|
-
|
123
|
+
Thread.new do
|
106
124
|
while children_to_add.present?
|
107
125
|
name = children_to_add.shift
|
108
126
|
unless name.nil?
|
@@ -115,7 +133,7 @@ describe 'Concurrent creation', if: support_concurrency do
|
|
115
133
|
end
|
116
134
|
run_destruction = true
|
117
135
|
destroyer_threads = @workers.times.map do
|
118
|
-
|
136
|
+
Thread.new do
|
119
137
|
begin
|
120
138
|
victim_name = children_to_delete.shift
|
121
139
|
if victim_name
|
@@ -130,27 +148,26 @@ describe 'Concurrent creation', if: support_concurrency do
|
|
130
148
|
end while run_destruction || !children_to_delete.empty?
|
131
149
|
end
|
132
150
|
end
|
133
|
-
creator_threads.each
|
134
|
-
|
135
|
-
destroyer_threads.each { |ea| ea.join }
|
151
|
+
creator_threads.each(&:join)
|
152
|
+
destroyer_threads.each(&:join)
|
136
153
|
expect(added_children).to match(expected_children)
|
137
154
|
expect(deleted_children).to match(expected_children)
|
138
155
|
end
|
139
156
|
|
140
|
-
|
157
|
+
it 'fails to deadlock while simultaneously deleting items from the same hierarchy' do
|
141
158
|
target = User.find_or_create_by_path((1..200).to_a.map { |ea| ea.to_s })
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
User.
|
159
|
+
emails = target.self_and_ancestors.to_a.map(&:email).shuffle
|
160
|
+
Parallel.map(emails, :in_threads => max_threads) do |email|
|
161
|
+
ActiveRecord::Base.connection_pool.with_connection do
|
162
|
+
User.transaction do
|
163
|
+
puts "Destroying #{email}..."
|
164
|
+
User.where(email: email).destroy_all
|
148
165
|
end
|
149
166
|
end
|
150
167
|
end
|
151
|
-
|
168
|
+
User.connection.reconnect!
|
152
169
|
expect(User.all).to be_empty
|
153
|
-
end
|
170
|
+
end unless sqlite? # sqlite throws errors from concurrent access
|
154
171
|
|
155
172
|
class SiblingPrependerWorker < WorkerBase
|
156
173
|
def before_work
|
@@ -158,7 +175,7 @@ describe 'Concurrent creation', if: support_concurrency do
|
|
158
175
|
@sibling = Label.new(name: SecureRandom.hex(10))
|
159
176
|
end
|
160
177
|
|
161
|
-
def
|
178
|
+
def work
|
162
179
|
@target.prepend_sibling @sibling
|
163
180
|
end
|
164
181
|
end
|
@@ -172,6 +189,6 @@ describe 'Concurrent creation', if: support_concurrency do
|
|
172
189
|
|
173
190
|
# The only non-root node should be "root":
|
174
191
|
expect(Label.all.select { |ea| ea.root? }).to eq([@target.parent])
|
175
|
-
end
|
192
|
+
end unless sqlite? # sqlite throws errors from concurrent access
|
176
193
|
|
177
|
-
end
|
194
|
+
end if run_parallel_tests?
|