closure_tree 6.4.0 → 7.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.gitignore +1 -0
- data/.travis.yml +19 -12
- data/Appraisals +75 -7
- data/CHANGELOG.md +92 -39
- data/Gemfile +0 -12
- data/README.md +67 -24
- data/_config.yml +1 -0
- data/closure_tree.gemspec +10 -7
- data/lib/closure_tree/finders.rb +32 -9
- data/lib/closure_tree/has_closure_tree.rb +4 -0
- data/lib/closure_tree/has_closure_tree_root.rb +4 -6
- data/lib/closure_tree/hash_tree_support.rb +4 -4
- data/lib/closure_tree/hierarchy_maintenance.rb +31 -11
- data/lib/closure_tree/model.rb +42 -16
- data/lib/closure_tree/numeric_deterministic_ordering.rb +20 -6
- data/lib/closure_tree/numeric_order_support.rb +7 -3
- data/lib/closure_tree/support.rb +18 -12
- data/lib/closure_tree/support_attributes.rb +10 -1
- data/lib/closure_tree/support_flags.rb +1 -4
- data/lib/closure_tree/version.rb +1 -1
- data/lib/generators/closure_tree/migration_generator.rb +8 -0
- data/lib/generators/closure_tree/templates/create_hierarchies_table.rb.erb +1 -1
- metadata +29 -75
- data/gemfiles/activerecord_4.2.gemfile +0 -19
- data/gemfiles/activerecord_5.0.gemfile +0 -19
- data/gemfiles/activerecord_5.0_foreigner.gemfile +0 -20
- data/gemfiles/activerecord_edge.gemfile +0 -20
- data/img/example.png +0 -0
- data/img/preorder.png +0 -0
- data/spec/cache_invalidation_spec.rb +0 -39
- data/spec/cuisine_type_spec.rb +0 -38
- data/spec/db/database.yml +0 -21
- data/spec/db/models.rb +0 -128
- data/spec/db/schema.rb +0 -166
- data/spec/fixtures/tags.yml +0 -98
- data/spec/generators/migration_generator_spec.rb +0 -48
- data/spec/has_closure_tree_root_spec.rb +0 -154
- data/spec/hierarchy_maintenance_spec.rb +0 -16
- data/spec/label_spec.rb +0 -554
- data/spec/matcher_spec.rb +0 -34
- data/spec/metal_spec.rb +0 -55
- data/spec/model_spec.rb +0 -9
- data/spec/namespace_type_spec.rb +0 -13
- data/spec/parallel_spec.rb +0 -159
- data/spec/spec_helper.rb +0 -24
- data/spec/support/database.rb +0 -52
- data/spec/support/database_cleaner.rb +0 -14
- data/spec/support/exceed_query_limit.rb +0 -18
- data/spec/support/hash_monkey_patch.rb +0 -13
- data/spec/support/query_counter.rb +0 -18
- data/spec/support/sqlite3_with_advisory_lock.rb +0 -10
- data/spec/support_spec.rb +0 -14
- data/spec/tag_examples.rb +0 -665
- data/spec/tag_spec.rb +0 -6
- data/spec/user_spec.rb +0 -174
- data/spec/uuid_tag_spec.rb +0 -6
data/spec/matcher_spec.rb
DELETED
@@ -1,34 +0,0 @@
|
|
1
|
-
require 'spec_helper'
|
2
|
-
|
3
|
-
describe 'ClosureTree::Test::Matcher' do
|
4
|
-
|
5
|
-
describe 'be_a_closure_tree' do
|
6
|
-
it { expect(UUIDTag).to be_a_closure_tree }
|
7
|
-
it { expect(User).to be_a_closure_tree }
|
8
|
-
it { expect(Label).to be_a_closure_tree.ordered }
|
9
|
-
it { expect(Metal).to be_a_closure_tree.ordered(:sort_order) }
|
10
|
-
it { expect(MenuItem).to be_a_closure_tree }
|
11
|
-
it { expect(Contract).not_to be_a_closure_tree }
|
12
|
-
end
|
13
|
-
|
14
|
-
describe 'ordered' do
|
15
|
-
it { expect(Label).to be_a_closure_tree.ordered }
|
16
|
-
it { expect(UUIDTag).to be_a_closure_tree.ordered }
|
17
|
-
it { expect(Metal).to be_a_closure_tree.ordered(:sort_order) }
|
18
|
-
end
|
19
|
-
|
20
|
-
describe 'advisory_lock' do
|
21
|
-
it 'should use advisory lock' do
|
22
|
-
expect(User).to be_a_closure_tree.with_advisory_lock
|
23
|
-
expect(Label).to be_a_closure_tree.ordered.with_advisory_lock
|
24
|
-
expect(Metal).to be_a_closure_tree.ordered(:sort_order).with_advisory_lock
|
25
|
-
end
|
26
|
-
|
27
|
-
describe MenuItem do
|
28
|
-
it 'should not use advisory lock' do
|
29
|
-
is_expected.to be_a_closure_tree.without_advisory_lock
|
30
|
-
end
|
31
|
-
end
|
32
|
-
end
|
33
|
-
|
34
|
-
end
|
data/spec/metal_spec.rb
DELETED
@@ -1,55 +0,0 @@
|
|
1
|
-
require 'spec_helper'
|
2
|
-
|
3
|
-
describe Metal do
|
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
|
54
|
-
end
|
55
|
-
end
|
data/spec/model_spec.rb
DELETED
data/spec/namespace_type_spec.rb
DELETED
@@ -1,13 +0,0 @@
|
|
1
|
-
require 'spec_helper'
|
2
|
-
|
3
|
-
describe Namespace::Type do
|
4
|
-
|
5
|
-
context "class injection" do
|
6
|
-
it "should build hierarchy classname correctly" do
|
7
|
-
expect(Namespace::Type.hierarchy_class.to_s).to eq("Namespace::TypeHierarchy")
|
8
|
-
expect(Namespace::Type._ct.hierarchy_class_name).to eq("Namespace::TypeHierarchy")
|
9
|
-
expect(Namespace::Type._ct.short_hierarchy_class_name).to eq("TypeHierarchy")
|
10
|
-
end
|
11
|
-
end
|
12
|
-
|
13
|
-
end
|
data/spec/parallel_spec.rb
DELETED
@@ -1,159 +0,0 @@
|
|
1
|
-
require 'spec_helper'
|
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
|
-
# SQLite doesn't support concurrency reliably, either.
|
6
|
-
def run_parallel_tests?
|
7
|
-
!sqlite? &&
|
8
|
-
ActiveRecord::Base.table_name_prefix.empty? &&
|
9
|
-
ActiveRecord::Base.table_name_suffix.empty?
|
10
|
-
end
|
11
|
-
|
12
|
-
def max_threads
|
13
|
-
5
|
14
|
-
end
|
15
|
-
|
16
|
-
class WorkerBase
|
17
|
-
extend Forwardable
|
18
|
-
attr_reader :name
|
19
|
-
def_delegators :@thread, :join, :wakeup, :status, :to_s
|
20
|
-
|
21
|
-
def log(msg)
|
22
|
-
puts("#{Thread.current}: #{msg}") if ENV['VERBOSE']
|
23
|
-
end
|
24
|
-
|
25
|
-
def initialize(target, name)
|
26
|
-
@target = target
|
27
|
-
@name = name
|
28
|
-
@thread = Thread.new do
|
29
|
-
ActiveRecord::Base.connection_pool.with_connection { before_work } if respond_to? :before_work
|
30
|
-
log 'going to sleep...'
|
31
|
-
sleep
|
32
|
-
log 'woke up...'
|
33
|
-
ActiveRecord::Base.connection_pool.with_connection { work }
|
34
|
-
log 'done.'
|
35
|
-
end
|
36
|
-
end
|
37
|
-
end
|
38
|
-
|
39
|
-
class FindOrCreateWorker < WorkerBase
|
40
|
-
def work
|
41
|
-
path = [name, :a, :b, :c]
|
42
|
-
log "making #{path}..."
|
43
|
-
t = (@target || Tag).find_or_create_by_path(path)
|
44
|
-
log "made #{t.id}, #{t.ancestry_path}"
|
45
|
-
end
|
46
|
-
end
|
47
|
-
|
48
|
-
describe 'Concurrent creation' do
|
49
|
-
before :each do
|
50
|
-
@target = nil
|
51
|
-
@iterations = 5
|
52
|
-
end
|
53
|
-
|
54
|
-
def log(msg)
|
55
|
-
puts(msg) if ENV['VERBOSE']
|
56
|
-
end
|
57
|
-
|
58
|
-
def run_workers(worker_class = FindOrCreateWorker)
|
59
|
-
@names = @iterations.times.map { |iter| "iteration ##{iter}" }
|
60
|
-
@names.each do |name|
|
61
|
-
workers = max_threads.times.map { worker_class.new(@target, name) }
|
62
|
-
# Wait for all the threads to get ready:
|
63
|
-
while true
|
64
|
-
unready_workers = workers.select { |ea| ea.status != 'sleep' }
|
65
|
-
if unready_workers.empty?
|
66
|
-
break
|
67
|
-
else
|
68
|
-
log "Not ready to wakeup: #{unready_workers.map { |ea| [ea.to_s, ea.status] }}"
|
69
|
-
sleep(0.1)
|
70
|
-
end
|
71
|
-
end
|
72
|
-
sleep(0.25)
|
73
|
-
# OK, GO!
|
74
|
-
log 'Calling .wakeup on all workers...'
|
75
|
-
workers.each(&:wakeup)
|
76
|
-
sleep(0.25)
|
77
|
-
# Then wait for them to finish:
|
78
|
-
log 'Calling .join on all workers...'
|
79
|
-
workers.each(&:join)
|
80
|
-
end
|
81
|
-
# Ensure we're still connected:
|
82
|
-
ActiveRecord::Base.connection_pool.connection
|
83
|
-
end
|
84
|
-
|
85
|
-
it 'will not create dupes from class methods' do
|
86
|
-
run_workers
|
87
|
-
expect(Tag.roots.collect { |ea| ea.name }).to match_array(@names)
|
88
|
-
# No dupe children:
|
89
|
-
%w(a b c).each do |ea|
|
90
|
-
expect(Tag.where(name: ea).size).to eq(@iterations)
|
91
|
-
end
|
92
|
-
end
|
93
|
-
|
94
|
-
it 'will not create dupes from instance methods' do
|
95
|
-
@target = Tag.create!(name: 'root')
|
96
|
-
run_workers
|
97
|
-
expect(@target.reload.children.collect { |ea| ea.name }).to match_array(@names)
|
98
|
-
expect(Tag.where(name: @names).size).to eq(@iterations)
|
99
|
-
%w(a b c).each do |ea|
|
100
|
-
expect(Tag.where(name: ea).size).to eq(@iterations)
|
101
|
-
end
|
102
|
-
end
|
103
|
-
|
104
|
-
it 'creates dupe roots without advisory locks' do
|
105
|
-
# disable with_advisory_lock:
|
106
|
-
allow(Tag).to receive(:with_advisory_lock) { |_lock_name, &block| block.call }
|
107
|
-
run_workers
|
108
|
-
# duplication from at least one iteration:
|
109
|
-
expect(Tag.where(name: @names).size).to be > @iterations
|
110
|
-
end
|
111
|
-
|
112
|
-
class SiblingPrependerWorker < WorkerBase
|
113
|
-
def before_work
|
114
|
-
@target.reload
|
115
|
-
@sibling = Label.new(name: SecureRandom.hex(10))
|
116
|
-
end
|
117
|
-
|
118
|
-
def work
|
119
|
-
@target.prepend_sibling @sibling
|
120
|
-
end
|
121
|
-
end
|
122
|
-
|
123
|
-
it 'fails to deadlock while simultaneously deleting items from the same hierarchy' do
|
124
|
-
target = User.find_or_create_by_path((1..200).to_a.map { |ea| ea.to_s })
|
125
|
-
emails = target.self_and_ancestors.to_a.map(&:email).shuffle
|
126
|
-
Parallel.map(emails, :in_threads => max_threads) do |email|
|
127
|
-
ActiveRecord::Base.connection_pool.with_connection do
|
128
|
-
User.transaction do
|
129
|
-
log "Destroying #{email}..."
|
130
|
-
User.where(email: email).destroy_all
|
131
|
-
end
|
132
|
-
end
|
133
|
-
end
|
134
|
-
User.connection.reconnect!
|
135
|
-
expect(User.all).to be_empty
|
136
|
-
end
|
137
|
-
|
138
|
-
class SiblingPrependerWorker < WorkerBase
|
139
|
-
def before_work
|
140
|
-
@target.reload
|
141
|
-
@sibling = Label.new(name: SecureRandom.hex(10))
|
142
|
-
end
|
143
|
-
|
144
|
-
def work
|
145
|
-
@target.prepend_sibling @sibling
|
146
|
-
end
|
147
|
-
end
|
148
|
-
|
149
|
-
it 'fails to deadlock from prepending siblings' do
|
150
|
-
@target = Label.find_or_create_by_path %w(root parent)
|
151
|
-
run_workers(SiblingPrependerWorker)
|
152
|
-
children = Label.roots
|
153
|
-
uniq_order_values = children.collect { |ea| ea.order_value }.uniq
|
154
|
-
expect(children.size).to eq(uniq_order_values.size)
|
155
|
-
|
156
|
-
# The only non-root node should be "root":
|
157
|
-
expect(Label.all.select { |ea| ea.root? }).to eq([@target.parent])
|
158
|
-
end
|
159
|
-
end if run_parallel_tests?
|
data/spec/spec_helper.rb
DELETED
@@ -1,24 +0,0 @@
|
|
1
|
-
$LOAD_PATH.unshift(File.dirname(__FILE__) + '/../lib')
|
2
|
-
|
3
|
-
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
|
4
|
-
require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
|
5
|
-
|
6
|
-
require 'database_cleaner'
|
7
|
-
require 'closure_tree/test/matcher'
|
8
|
-
require 'tmpdir'
|
9
|
-
require 'timecop'
|
10
|
-
require 'forwardable'
|
11
|
-
require 'parallel'
|
12
|
-
begin
|
13
|
-
require 'foreigner'
|
14
|
-
rescue LoadError
|
15
|
-
#Foreigner is not needed in ActiveRecord 4.2+
|
16
|
-
end
|
17
|
-
|
18
|
-
Thread.abort_on_exception = true
|
19
|
-
|
20
|
-
Dir['./spec/support/**/*.rb'].sort.each { |f| require f }
|
21
|
-
|
22
|
-
RSpec.configure do |config|
|
23
|
-
config.include ClosureTree::Test::Matcher
|
24
|
-
end
|
data/spec/support/database.rb
DELETED
@@ -1,52 +0,0 @@
|
|
1
|
-
database_folder = "#{File.dirname(__FILE__)}/../db"
|
2
|
-
database_adapter = ENV['DB'] ||= 'postgresql'
|
3
|
-
|
4
|
-
def sqlite?
|
5
|
-
ENV['DB'] == 'sqlite'
|
6
|
-
end
|
7
|
-
|
8
|
-
log = Logger.new('db.log')
|
9
|
-
log.sev_threshold = Logger::DEBUG
|
10
|
-
ActiveRecord::Base.logger = log
|
11
|
-
|
12
|
-
ActiveRecord::Migration.verbose = false
|
13
|
-
ActiveRecord::Base.table_name_prefix = ENV['DB_PREFIX'].to_s
|
14
|
-
ActiveRecord::Base.table_name_suffix = ENV['DB_SUFFIX'].to_s
|
15
|
-
|
16
|
-
def db_name
|
17
|
-
@db_name ||= "closure_tree_test_#{rand(1..2**31)}"
|
18
|
-
end
|
19
|
-
|
20
|
-
ActiveRecord::Base.configurations = YAML::load(ERB.new(IO.read("#{database_folder}/database.yml")).result)
|
21
|
-
|
22
|
-
config = ActiveRecord::Base.configurations[database_adapter]
|
23
|
-
|
24
|
-
begin
|
25
|
-
case database_adapter
|
26
|
-
when 'sqlite'
|
27
|
-
ActiveRecord::Base.establish_connection(database_adapter.to_sym)
|
28
|
-
when 'mysql'
|
29
|
-
ActiveRecord::Base.establish_connection(config.merge('database' => nil))
|
30
|
-
ActiveRecord::Base.connection.recreate_database(config['database'], {charset: 'utf8', collation: 'utf8_unicode_ci'})
|
31
|
-
when 'postgresql'
|
32
|
-
ActiveRecord::Base.establish_connection(config.merge('database' => 'postgres', 'schema_search_path' => 'public'))
|
33
|
-
ActiveRecord::Base.connection.recreate_database(config['database'], config.merge('encoding' => 'utf8'))
|
34
|
-
end
|
35
|
-
end unless ENV['NONUKES']
|
36
|
-
|
37
|
-
ActiveRecord::Base.establish_connection(config)
|
38
|
-
# Drop this when support for ActiveRecord 4.1 is removed
|
39
|
-
Foreigner.load if defined?(Foreigner)
|
40
|
-
|
41
|
-
require "#{database_folder}/schema"
|
42
|
-
require "#{database_folder}/models"
|
43
|
-
|
44
|
-
# See http://stackoverflow.com/a/22388177/1268016
|
45
|
-
def count_queries(&block)
|
46
|
-
count = 0
|
47
|
-
counter_fn = ->(name, started, finished, unique_id, payload) do
|
48
|
-
count += 1 unless %w[CACHE SCHEMA].include? payload[:name]
|
49
|
-
end
|
50
|
-
ActiveSupport::Notifications.subscribed(counter_fn, 'sql.active_record', &block)
|
51
|
-
count
|
52
|
-
end
|
@@ -1,14 +0,0 @@
|
|
1
|
-
RSpec.configure do |config|
|
2
|
-
|
3
|
-
DatabaseCleaner.strategy = :truncation
|
4
|
-
|
5
|
-
config.before(:each) do
|
6
|
-
ActiveRecord::Base.connection_pool.connection
|
7
|
-
DatabaseCleaner.start
|
8
|
-
end
|
9
|
-
|
10
|
-
config.after(:each) do
|
11
|
-
ActiveRecord::Base.connection_pool.connection
|
12
|
-
DatabaseCleaner.clean
|
13
|
-
end
|
14
|
-
end
|
@@ -1,18 +0,0 @@
|
|
1
|
-
# Derived from http://stackoverflow.com/a/13423584/153896. Updated for RSpec 3.
|
2
|
-
RSpec::Matchers.define :exceed_query_limit do |expected|
|
3
|
-
supports_block_expectations
|
4
|
-
|
5
|
-
match do |block|
|
6
|
-
query_count(&block) > expected
|
7
|
-
end
|
8
|
-
|
9
|
-
failure_message_when_negated do |actual|
|
10
|
-
"Expected to run maximum #{expected} queries, got #{@counter.query_count}"
|
11
|
-
end
|
12
|
-
|
13
|
-
def query_count(&block)
|
14
|
-
@counter = ActiveRecord::QueryCounter.new
|
15
|
-
ActiveSupport::Notifications.subscribed(@counter.to_proc, 'sql.active_record', &block)
|
16
|
-
@counter.query_count
|
17
|
-
end
|
18
|
-
end
|
@@ -1,18 +0,0 @@
|
|
1
|
-
# From http://stackoverflow.com/a/13423584/153896
|
2
|
-
module ActiveRecord
|
3
|
-
class QueryCounter
|
4
|
-
attr_reader :query_count
|
5
|
-
|
6
|
-
def initialize
|
7
|
-
@query_count = 0
|
8
|
-
end
|
9
|
-
|
10
|
-
def to_proc
|
11
|
-
lambda(&method(:callback))
|
12
|
-
end
|
13
|
-
|
14
|
-
def callback(name, start, finish, message_id, values)
|
15
|
-
@query_count += 1 unless %w(CACHE SCHEMA).include?(values[:name])
|
16
|
-
end
|
17
|
-
end
|
18
|
-
end
|
data/spec/support_spec.rb
DELETED
@@ -1,14 +0,0 @@
|
|
1
|
-
require 'spec_helper'
|
2
|
-
|
3
|
-
describe ClosureTree::Support do
|
4
|
-
let(:sut) { Tag._ct }
|
5
|
-
it 'passes through table names without prefix and suffix' do
|
6
|
-
expected = 'some_random_table_name'
|
7
|
-
expect(sut.remove_prefix_and_suffix(expected)).to eq(expected)
|
8
|
-
end
|
9
|
-
it 'extracts through table name with prefix and suffix' do
|
10
|
-
expected = 'some_random_table_name'
|
11
|
-
tn = ActiveRecord::Base.table_name_prefix + expected + ActiveRecord::Base.table_name_suffix
|
12
|
-
expect(sut.remove_prefix_and_suffix(tn)).to eq(expected)
|
13
|
-
end
|
14
|
-
end
|