closure_tree 4.6.3 → 5.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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?
|