hyrarchy 0.3.3
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.
- data/LICENSE +20 -0
- data/README.rdoc +69 -0
- data/lib/hyrarchy.rb +321 -0
- data/lib/hyrarchy/awesome_nested_set_compatibility.rb +375 -0
- data/lib/hyrarchy/collection_proxy.rb +75 -0
- data/lib/hyrarchy/encoded_path.rb +111 -0
- data/rails_plugin/init.rb +2 -0
- data/spec/create_nodes_table.rb +25 -0
- data/spec/database.yml +10 -0
- data/spec/hyrarchy_spec.rb +184 -0
- data/spec/spec_helper.rb +52 -0
- data/test/encoded_path_test.rb +31 -0
- data/test/test_helper.rb +6 -0
- metadata +82 -0
@@ -0,0 +1,75 @@
|
|
1
|
+
module Hyrarchy
|
2
|
+
# This is a shameful hack to create has_many associations with no foreign key
|
3
|
+
# and an option for running a post-processing procedure on the array of
|
4
|
+
# records. Hyrarchy uses this class to provide the features of a has_many
|
5
|
+
# association on a node's ancestors and descendants arrays.
|
6
|
+
class CollectionProxy < ActiveRecord::Associations::HasManyAssociation # :nodoc:
|
7
|
+
def initialize(owner, name, options = {})
|
8
|
+
@after = options.delete(:after)
|
9
|
+
@count = options.delete(:count)
|
10
|
+
@index = options.delete(:index)
|
11
|
+
reflection = ActiveRecord::Base.create_reflection(
|
12
|
+
:has_many, name, options.merge(:class_name => owner.class.to_s), owner.class)
|
13
|
+
super(owner, reflection)
|
14
|
+
end
|
15
|
+
|
16
|
+
# This is ripped right from the construct_sql method in HasManyAssociation,
|
17
|
+
# but the foreign key condition has been removed.
|
18
|
+
def construct_sql
|
19
|
+
if @reflection.options[:finder_sql]
|
20
|
+
@finder_sql = interpolate_sql(@reflection.options[:finder_sql])
|
21
|
+
else
|
22
|
+
@finder_sql = conditions
|
23
|
+
end
|
24
|
+
|
25
|
+
if @reflection.options[:counter_sql]
|
26
|
+
@counter_sql = interpolate_sql(@reflection.options[:counter_sql])
|
27
|
+
elsif @reflection.options[:finder_sql]
|
28
|
+
# replace the SELECT clause with COUNT(*), preserving any hints within /* ... */
|
29
|
+
@reflection.options[:counter_sql] = @reflection.options[:finder_sql].sub(/SELECT (\/\*.*?\*\/ )?(.*)\bFROM\b/im) { "SELECT #{$1}COUNT(*) FROM" }
|
30
|
+
@counter_sql = interpolate_sql(@reflection.options[:counter_sql])
|
31
|
+
else
|
32
|
+
@counter_sql = @finder_sql
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Overrides find to run the association's +after+ procedure on the results.
|
37
|
+
def find(*args)
|
38
|
+
records = super
|
39
|
+
@after.call(records) if @after
|
40
|
+
records
|
41
|
+
end
|
42
|
+
|
43
|
+
# Overrides count to run the association's +count+ procedure, with caching.
|
44
|
+
def count
|
45
|
+
if @count
|
46
|
+
if @count.respond_to?(:call)
|
47
|
+
@count = @count.call
|
48
|
+
else
|
49
|
+
@count
|
50
|
+
end
|
51
|
+
else
|
52
|
+
super
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# Overrides index to run the association's +index+ procedure.
|
57
|
+
def index(obj)
|
58
|
+
if @index && !loaded?
|
59
|
+
@index.call(obj)
|
60
|
+
else
|
61
|
+
super
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
protected
|
66
|
+
|
67
|
+
# Overrides find_target to run the association's +after+ procedure on the
|
68
|
+
# results.
|
69
|
+
def find_target
|
70
|
+
records = super
|
71
|
+
@after.call(records) if @after
|
72
|
+
records
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
require 'rational'
|
2
|
+
|
3
|
+
module Hyrarchy
|
4
|
+
# Returns a new path with numerator +n+ and denominator +d+, which will be
|
5
|
+
# reduced if possible. Paths must be in the interval [0,1]. This method
|
6
|
+
# correlates to the Rational(n, d) method.
|
7
|
+
def self.EncodedPath(n, d) # :nodoc:
|
8
|
+
r = EncodedPath.reduce n, d
|
9
|
+
raise(RangeError, "paths must be in the interval [0,1]") if r < 0 || r > 1
|
10
|
+
r
|
11
|
+
end
|
12
|
+
|
13
|
+
# An encoded path is a rational number that represents a node's position in
|
14
|
+
# the tree. By using rational numbers instead of integers, new nodes can be
|
15
|
+
# inserted arbitrarily without having to adjust the left and right values of
|
16
|
+
# any other nodes. Farey sequences are used to prevent denominators from
|
17
|
+
# growing exponentially and quickly exhausting the database's integer range.
|
18
|
+
# For more information, see "Nested Intervals with Farey Fractions" by Vadim
|
19
|
+
# Tropashko: http://arxiv.org/html/cs.DB/0401014
|
20
|
+
class EncodedPath < Rational # :nodoc:
|
21
|
+
# Path of the uppermost node in the tree. The node at this path has no
|
22
|
+
# siblings, and all nodes descend from it.
|
23
|
+
ROOT = Hyrarchy::EncodedPath(0, 1)
|
24
|
+
|
25
|
+
# Returns the path of the parent of the node at this path. If +root_is_nil+
|
26
|
+
# is true (the default) and the parent is the root node, returns nil.
|
27
|
+
def parent(root_is_nil = true)
|
28
|
+
r = next_farey_fraction
|
29
|
+
p = Hyrarchy::EncodedPath(
|
30
|
+
numerator - r.numerator,
|
31
|
+
denominator - r.denominator)
|
32
|
+
(root_is_nil && p == ROOT) ? nil : p
|
33
|
+
end
|
34
|
+
|
35
|
+
# Returns the depth of the node at this path, starting from the root node.
|
36
|
+
# Paths in the uppermost layer (considered "root nodes" by the ActiveRecord
|
37
|
+
# methods) have a depth of one.
|
38
|
+
def depth
|
39
|
+
n = self
|
40
|
+
depth = 0
|
41
|
+
while n != ROOT
|
42
|
+
n = n.parent(false)
|
43
|
+
depth += 1
|
44
|
+
end
|
45
|
+
depth
|
46
|
+
end
|
47
|
+
|
48
|
+
# Returns the path of the first child of the node at this path.
|
49
|
+
def first_child
|
50
|
+
mediant(next_farey_fraction)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Returns the path of the sibling immediately after the node at this path.
|
54
|
+
def next_sibling
|
55
|
+
parent(false).mediant(self)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Returns the path of the sibling immediately before the node at this path.
|
59
|
+
# If this is the path of the first sibling, returns nil.
|
60
|
+
def previous_sibling
|
61
|
+
p = parent(false)
|
62
|
+
return nil if self == p.first_child
|
63
|
+
Hyrarchy::EncodedPath(
|
64
|
+
numerator - p.numerator,
|
65
|
+
denominator - p.denominator)
|
66
|
+
end
|
67
|
+
|
68
|
+
# Finds the mediant of this fraction and +other+.
|
69
|
+
def mediant(other)
|
70
|
+
Hyrarchy::EncodedPath(
|
71
|
+
numerator + other.numerator,
|
72
|
+
denominator + other.denominator)
|
73
|
+
end
|
74
|
+
|
75
|
+
# Returns the fraction immediately after this one in the Farey sequence
|
76
|
+
# whose order is this fraction's denominator. This is the find-neighbors
|
77
|
+
# algorithm from "Rounding rational numbers using Farey/Cauchy sequence" by
|
78
|
+
# Wim Lewis: http://www.hhhh.org/wiml/proj/farey
|
79
|
+
def next_farey_fraction
|
80
|
+
# Handle the special case of the last fraction.
|
81
|
+
return nil if self == Rational(1, 1)
|
82
|
+
# Compute the modular multiplicative inverses of the numerator and
|
83
|
+
# denominator using an iterative extended Euclidean algorithm. These
|
84
|
+
# inverses are the denominator and negative numerator of the fraction
|
85
|
+
# preceding this one, modulo the numerator and denominator of this
|
86
|
+
# fraction.
|
87
|
+
a, b = [numerator, denominator]
|
88
|
+
x, lastx, y, lasty = [0, 1, 1, 0]
|
89
|
+
while b != 0
|
90
|
+
a, b, q = [b, a % b, a / b]
|
91
|
+
x, lastx = [lastx - q * x, x]
|
92
|
+
y, lasty = [lasty - q * y, y]
|
93
|
+
end
|
94
|
+
qL, pL = [lastx, -lasty]
|
95
|
+
# Find the numerator and denominator of the fraction following this one
|
96
|
+
# using the mediant relationship between it, this fraction, and the
|
97
|
+
# preceding fraction. The modulo ambiguity is resolved by brute force,
|
98
|
+
# which is probably not the smartest way to do it, but it's fast enough.
|
99
|
+
i = 0
|
100
|
+
while true do
|
101
|
+
a = pL + numerator * i
|
102
|
+
b = qL + denominator * i
|
103
|
+
if (numerator * b - denominator * a == 1) &&
|
104
|
+
(Rational(numerator - a, denominator - b).denominator <= denominator)
|
105
|
+
return Hyrarchy::EncodedPath(numerator - a, denominator - b)
|
106
|
+
end
|
107
|
+
i += 1
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
gem 'sqlite3-ruby'
|
3
|
+
require 'activerecord'
|
4
|
+
require 'yaml'
|
5
|
+
|
6
|
+
$: << File.join(File.dirname(__FILE__), '..', 'lib')
|
7
|
+
require 'hyrarchy'
|
8
|
+
Hyrarchy.activate!
|
9
|
+
|
10
|
+
db_specs = YAML.load_file(File.join(File.dirname(__FILE__), 'database.yml'))
|
11
|
+
which_spec = ENV['DB'] || 'mysql'
|
12
|
+
ActiveRecord::Base.establish_connection(db_specs[which_spec])
|
13
|
+
|
14
|
+
class CreateNodesTable < ActiveRecord::Migration
|
15
|
+
def self.up
|
16
|
+
create_table :nodes do |t|
|
17
|
+
t.string :name, :null => false
|
18
|
+
end
|
19
|
+
add_hierarchy :nodes
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.down
|
23
|
+
drop_table :nodes
|
24
|
+
end
|
25
|
+
end
|
data/spec/database.yml
ADDED
@@ -0,0 +1,184 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'spec_helper')
|
2
|
+
|
3
|
+
describe Hyrarchy do
|
4
|
+
describe "(functionality)" do
|
5
|
+
before(:all) do
|
6
|
+
Node.delete_all
|
7
|
+
|
8
|
+
@roots = [
|
9
|
+
Node.create!(:name => 'root 0'),
|
10
|
+
Node.create!(:name => 'root 1'),
|
11
|
+
Node.create!(:name => 'root 2')
|
12
|
+
]
|
13
|
+
@layer1 = [
|
14
|
+
Node.create!(:name => '1.0', :parent => @roots[1]),
|
15
|
+
Node.create!(:name => '1.1', :parent => @roots[1]),
|
16
|
+
Node.create!(:name => '1.2', :parent => @roots[1])
|
17
|
+
]
|
18
|
+
@layer2 = [
|
19
|
+
Node.create!(:name => '1.0.0', :parent => @layer1[0]),
|
20
|
+
Node.create!(:name => '1.0.1', :parent => @layer1[0]),
|
21
|
+
Node.create!(:name => '1.1.0', :parent => @layer1[1]),
|
22
|
+
Node.create!(:name => '1.1.1', :parent => @layer1[1]),
|
23
|
+
Node.create!(:name => '1.2.0', :parent => @layer1[2]),
|
24
|
+
Node.create!(:name => '1.2.1', :parent => @layer1[2])
|
25
|
+
]
|
26
|
+
|
27
|
+
@roots.collect! {|n| Node.find(n.id)}
|
28
|
+
@layer1.collect! {|n| Node.find(n.id)}
|
29
|
+
@layer2.collect! {|n| Node.find(n.id)}
|
30
|
+
end
|
31
|
+
|
32
|
+
it "should find its parent" do
|
33
|
+
@layer2[0].parent.should == @layer1[0]
|
34
|
+
@layer2[1].parent.should == @layer1[0]
|
35
|
+
@layer2[2].parent.should == @layer1[1]
|
36
|
+
@layer2[3].parent.should == @layer1[1]
|
37
|
+
@layer2[4].parent.should == @layer1[2]
|
38
|
+
@layer2[5].parent.should == @layer1[2]
|
39
|
+
@layer1.each {|n| n.parent.should == @roots[1]}
|
40
|
+
@roots.each {|n| n.parent.should == nil}
|
41
|
+
end
|
42
|
+
|
43
|
+
it "should find its descendants" do
|
44
|
+
returned_descendants = @roots[1].descendants
|
45
|
+
returned_descendants.sort! {|a,b| a.name <=> b.name}
|
46
|
+
actual_descendants = @layer1 + @layer2
|
47
|
+
actual_descendants.sort! {|a,b| a.name <=> b.name}
|
48
|
+
returned_descendants.should == actual_descendants
|
49
|
+
@roots[0].descendants.should be_empty
|
50
|
+
@roots[2].descendants.should be_empty
|
51
|
+
end
|
52
|
+
|
53
|
+
it "should find its children" do
|
54
|
+
@roots[0].children.should be_empty
|
55
|
+
@roots[1].children.should == @layer1
|
56
|
+
@roots[2].children.should be_empty
|
57
|
+
@layer1[0].children.should == [@layer2[0], @layer2[1]]
|
58
|
+
@layer1[1].children.should == [@layer2[2], @layer2[3]]
|
59
|
+
@layer1[2].children.should == [@layer2[4], @layer2[5]]
|
60
|
+
@layer2.each {|n| n.children.should be_empty}
|
61
|
+
end
|
62
|
+
|
63
|
+
it "should find its ancestors" do
|
64
|
+
@layer2[0].ancestors.should == [@layer1[0], @roots[1]]
|
65
|
+
@layer2[1].ancestors.should == [@layer1[0], @roots[1]]
|
66
|
+
@layer2[2].ancestors.should == [@layer1[1], @roots[1]]
|
67
|
+
@layer2[3].ancestors.should == [@layer1[1], @roots[1]]
|
68
|
+
@layer2[4].ancestors.should == [@layer1[2], @roots[1]]
|
69
|
+
@layer2[5].ancestors.should == [@layer1[2], @roots[1]]
|
70
|
+
@layer1.each {|n| n.ancestors.should == [@roots[1]]}
|
71
|
+
@roots.each {|n| n.ancestors.should be_empty}
|
72
|
+
end
|
73
|
+
|
74
|
+
it "should find all root nodes" do
|
75
|
+
Node.roots.should == @roots
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
describe "(data integrity)" do
|
80
|
+
before(:each) do
|
81
|
+
Node.delete_all
|
82
|
+
|
83
|
+
@roots = [
|
84
|
+
Node.create!(:name => 'root 0'),
|
85
|
+
Node.create!(:name => 'root 1'),
|
86
|
+
Node.create!(:name => 'root 2')
|
87
|
+
]
|
88
|
+
@layer1 = [
|
89
|
+
Node.create!(:name => '1.0', :parent => @roots[1]),
|
90
|
+
Node.create!(:name => '1.1', :parent => @roots[1]),
|
91
|
+
Node.create!(:name => '1.2', :parent => @roots[1])
|
92
|
+
]
|
93
|
+
@layer2 = [
|
94
|
+
Node.create!(:name => '1.0.0', :parent => @layer1[0]),
|
95
|
+
Node.create!(:name => '1.0.1', :parent => @layer1[0]),
|
96
|
+
Node.create!(:name => '1.1.0', :parent => @layer1[1]),
|
97
|
+
Node.create!(:name => '1.1.1', :parent => @layer1[1]),
|
98
|
+
Node.create!(:name => '1.2.0', :parent => @layer1[2]),
|
99
|
+
Node.create!(:name => '1.2.1', :parent => @layer1[2])
|
100
|
+
]
|
101
|
+
|
102
|
+
@roots.collect! {|n| Node.find(n.id)}
|
103
|
+
@layer1.collect! {|n| Node.find(n.id)}
|
104
|
+
@layer2.collect! {|n| Node.find(n.id)}
|
105
|
+
end
|
106
|
+
|
107
|
+
it "should keep its descendants if it's moved to a different parent" do
|
108
|
+
@roots[1].parent = @roots[2]
|
109
|
+
@roots[1].save!
|
110
|
+
|
111
|
+
returned_descendants = @roots[2].descendants
|
112
|
+
returned_descendants.sort! {|a,b| a.name <=> b.name}
|
113
|
+
actual_descendants = @layer1 + @layer2 + [@roots[1]]
|
114
|
+
actual_descendants.sort! {|a,b| a.name <=> b.name}
|
115
|
+
returned_descendants.should == actual_descendants
|
116
|
+
@roots[0].descendants.should be_empty
|
117
|
+
|
118
|
+
actual_descendants.delete(@roots[1])
|
119
|
+
returned_descendants = @roots[1].descendants
|
120
|
+
returned_descendants.sort! {|a,b| a.name <=> b.name}
|
121
|
+
returned_descendants.should == actual_descendants
|
122
|
+
end
|
123
|
+
|
124
|
+
it "should destroy its descendants if it's destroyed" do
|
125
|
+
@roots[1].destroy
|
126
|
+
(@layer1 + @layer2).each do |node|
|
127
|
+
lambda { Node.find(node.id) }.should raise_error(ActiveRecord::RecordNotFound)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
describe "(performance)" do
|
133
|
+
SAMPLE_SIZE = 15000
|
134
|
+
LAYERS = 10
|
135
|
+
TIME_SPEC = ENV['DB'] == 'sqlite' ? 0.2 : 0.1
|
136
|
+
|
137
|
+
def test_times(times)
|
138
|
+
(times.mean + 3 * times.stddev).should satisfy {|n| n < TIME_SPEC}
|
139
|
+
slope, offset = linear_regression(times)
|
140
|
+
(slope * 1_000_000 + offset).should satisfy {|n| n < TIME_SPEC}
|
141
|
+
end
|
142
|
+
|
143
|
+
unless ENV['SKIP_PERFORMANCE']
|
144
|
+
it "should scale with constant insertion and access times < #{(TIME_SPEC * 1000).to_i}ms" do
|
145
|
+
Node.connection.execute("TRUNCATE TABLE #{Node.quoted_table_name}") rescue Node.delete_all
|
146
|
+
insertion_times = NArray.float(SAMPLE_SIZE)
|
147
|
+
parent_times = NArray.float(SAMPLE_SIZE)
|
148
|
+
children_times = NArray.float(SAMPLE_SIZE)
|
149
|
+
ancestors_times = NArray.float(SAMPLE_SIZE)
|
150
|
+
descendants_times = NArray.float(SAMPLE_SIZE)
|
151
|
+
|
152
|
+
i = -1
|
153
|
+
layer = []
|
154
|
+
(SAMPLE_SIZE / LAYERS).times do |j|
|
155
|
+
insertion_times[i+=1] = measure_time { layer << Node.create!(:name => j.to_s) }
|
156
|
+
end
|
157
|
+
(LAYERS-1).times do
|
158
|
+
new_layer = []
|
159
|
+
(SAMPLE_SIZE / LAYERS).times do |j|
|
160
|
+
parent = layer[rand(layer.length)]
|
161
|
+
insertion_times[i+=1] = measure_time { new_layer << Node.create!(:name => j.to_s, :parent => parent) }
|
162
|
+
end
|
163
|
+
layer = new_layer
|
164
|
+
end
|
165
|
+
|
166
|
+
ids = Node.connection.select_all("SELECT id FROM #{Node.quoted_table_name}")
|
167
|
+
ids.collect! {|row| row["id"].to_i}
|
168
|
+
SAMPLE_SIZE.times do |i|
|
169
|
+
node = Node.find(ids[rand(ids.length)])
|
170
|
+
parent_times[i] = measure_time { node.parent }
|
171
|
+
children_times[i] = measure_time { node.children }
|
172
|
+
ancestors_times[i] = measure_time { node.ancestors }
|
173
|
+
descendants_times[i] = measure_time { node.descendants }
|
174
|
+
end
|
175
|
+
|
176
|
+
test_times(insertion_times)
|
177
|
+
test_times(parent_times)
|
178
|
+
test_times(children_times)
|
179
|
+
test_times(ancestors_times)
|
180
|
+
test_times(descendants_times)
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
gem 'sqlite3-ruby'
|
3
|
+
require 'spec'
|
4
|
+
require 'activerecord'
|
5
|
+
require 'yaml'
|
6
|
+
require 'narray'
|
7
|
+
|
8
|
+
# Load and activate Hyrarchy.
|
9
|
+
$: << File.join(File.dirname(__FILE__), '..', 'lib')
|
10
|
+
require 'hyrarchy'
|
11
|
+
Hyrarchy.activate!
|
12
|
+
|
13
|
+
# Set up a logger.
|
14
|
+
log_path = File.join(File.dirname(__FILE__), 'log')
|
15
|
+
File.unlink(log_path) rescue nil
|
16
|
+
ActiveRecord::Base.logger = ActiveSupport::BufferedLogger.new(log_path)
|
17
|
+
ActiveRecord::Base.logger.add 0, "\n"
|
18
|
+
|
19
|
+
# Connect to the test database.
|
20
|
+
db_specs = YAML.load_file(File.join(File.dirname(__FILE__), 'database.yml'))
|
21
|
+
which_spec = ENV['DB'] || 'mysql'
|
22
|
+
ActiveRecord::Base.establish_connection(db_specs[which_spec])
|
23
|
+
|
24
|
+
# Create a model class for testing.
|
25
|
+
class Node < ActiveRecord::Base
|
26
|
+
is_hierarchic
|
27
|
+
connection.execute("TRUNCATE TABLE #{quoted_table_name}") rescue delete_all
|
28
|
+
def inspect; name end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Runs a block and returns how long it took in seconds (with subsecond
|
32
|
+
# precision).
|
33
|
+
def measure_time(&block)
|
34
|
+
start_time = Time.now
|
35
|
+
yield
|
36
|
+
Time.now - start_time
|
37
|
+
end
|
38
|
+
|
39
|
+
# Calculates the slope and offset of a data set.
|
40
|
+
def linear_regression(data)
|
41
|
+
sxx = sxy = sx = sy = 0
|
42
|
+
data.length.times do |x|
|
43
|
+
y = data[x]
|
44
|
+
sxy += x*y
|
45
|
+
sxx += x*x
|
46
|
+
sx += x
|
47
|
+
sy += y
|
48
|
+
end
|
49
|
+
slope = (data.length * sxy - sx * sy) / (data.length * sxx - sx * sx)
|
50
|
+
offset = (sy - slope * sx) / data.length
|
51
|
+
[slope, offset]
|
52
|
+
end
|