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.
@@ -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,2 @@
1
+ require 'hyrarchy'
2
+ Hyrarchy.activate!
@@ -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,10 @@
1
+ mysql:
2
+ adapter: mysql
3
+ host: localhost
4
+ database: hyrarchy_test
5
+ username: root
6
+ password:
7
+
8
+ sqlite:
9
+ adapter: sqlite3
10
+ database: spec/test.sqlite3
@@ -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
@@ -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