DanaDanger-hyrarchy 0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +20 -0
- data/README.rdoc +69 -0
- data/lib/hyrarchy.rb +317 -0
- data/lib/hyrarchy/awesome_nested_set_compatibility.rb +295 -0
- data/lib/hyrarchy/collection_proxy.rb +65 -0
- data/lib/hyrarchy/encoded_path.rb +101 -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 +73 -0
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2008 The Indianapolis Star
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.rdoc
ADDED
@@ -0,0 +1,69 @@
|
|
1
|
+
= Hyrarchy
|
2
|
+
|
3
|
+
Hyrarchy (Hybrid hieRarchy) is a gem and Rails plugin for working with hierarchic data in ActiveRecord. Your models gain methods for finding an instance's parent, children, ancestors, descendants, and depth, as well as a named scope for finding root nodes.
|
4
|
+
|
5
|
+
To use Hyrarchy in your Rails app, copy the plugin from the gem into your app's vendors/plugins directory. (The plugin is just a two-liner that loads and activates the gem.)
|
6
|
+
|
7
|
+
To use Hyrarchy in one of your models, add the following line to the class:
|
8
|
+
|
9
|
+
class Comment < ActiveRecord::Base
|
10
|
+
is_hierarchic
|
11
|
+
end
|
12
|
+
|
13
|
+
Then add the hierarchic columns to the model's database table:
|
14
|
+
|
15
|
+
class MakeCommentsHierarchic < ActiveRecord::Migration
|
16
|
+
def self.up
|
17
|
+
add_hierarchy :comments
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.down
|
21
|
+
remove_hierarchy :comments
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
Or you can put it in the same migration as the table's creation:
|
26
|
+
|
27
|
+
class CreateCommentsTable < ActiveRecord::Migration
|
28
|
+
def self.up
|
29
|
+
create_table :comments do |t|
|
30
|
+
t.integer :author_id
|
31
|
+
t.text :body
|
32
|
+
end
|
33
|
+
add_hierarchy :comments
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.down
|
37
|
+
drop_table :comments
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
== Performance
|
42
|
+
|
43
|
+
On MySQL, Hyrarchy scales to at least one million nodes with insertion and access times below 100ms. On SQLite, times are below 200ms.
|
44
|
+
|
45
|
+
== Database Compatibility
|
46
|
+
|
47
|
+
Hyrarchy has been tested on MySQL 5 and SQLite 3.
|
48
|
+
|
49
|
+
== Replacing awesome_nested_set
|
50
|
+
|
51
|
+
Hyrarchy is designed to be an almost-drop-in replacement for awesome_nested_set. All of awesome_nested_set's methods are implemented by Hyrarchy, but you'll need to replace calls to acts_as_nested_set with is_hierarchic. You'll also need to replace awesome_nested_set's database columns with Hyrarchy's, which you can do with an option to the add_hierarchy migration method:
|
52
|
+
|
53
|
+
add_hierarchy :comments, :convert => :awesome_nested_set
|
54
|
+
|
55
|
+
The convert option will modify the table structure but it won't rebuild the hierarchy information. You can rebuild it by calling rebuild! on your hierarchic model class:
|
56
|
+
|
57
|
+
Comment.rebuild!
|
58
|
+
|
59
|
+
The same option can be used with remove_hierarchy for the down half of a migration.
|
60
|
+
|
61
|
+
Hyrarchy doesn't yet support awesome_nested_set's scoping feature or its view helper.
|
62
|
+
|
63
|
+
== Implementation Details
|
64
|
+
|
65
|
+
Under the hood, Hyrarchy uses a combination of an adjacency list and a rational nested set. The nested set uses a technique developed by (I think) Vadim Tropashko, in which the left and right values are generated using Farey sequences. This makes it possible to insert new records without adjusting the left and right values of any other records. It also makes it possible to do many operations (like determining a record's depth in the tree) without accessing the database. For operations where rational nested sets perform poorly (such as finding a node's immediate descendants), the adjacency list is used.
|
66
|
+
|
67
|
+
== Credits and Copyright
|
68
|
+
|
69
|
+
Heavily based on works by Vadim Tropashko and Wim Lewis. Implemented by Dana Danger. Tolerated by VivaZoya. Copyright (c) 2008 The Indianapolis Star, released under the MIT license. See LICENSE for details.
|
data/lib/hyrarchy.rb
ADDED
@@ -0,0 +1,317 @@
|
|
1
|
+
require 'hyrarchy/encoded_path'
|
2
|
+
require 'hyrarchy/collection_proxy'
|
3
|
+
require 'hyrarchy/awesome_nested_set_compatibility'
|
4
|
+
|
5
|
+
module Hyrarchy
|
6
|
+
# Fudge factor to account for imprecision with floating point approximations
|
7
|
+
# of a node's left and right fractions.
|
8
|
+
FLOAT_FUDGE_FACTOR = 0.0000000000001 # :nodoc:
|
9
|
+
|
10
|
+
# Mixes Hyrarchy into ActiveRecord.
|
11
|
+
def self.activate!
|
12
|
+
ActiveRecord::Base.extend IsHierarchic
|
13
|
+
ActiveRecord::Migration.extend Migrations
|
14
|
+
end
|
15
|
+
|
16
|
+
# These methods are available in ActiveRecord migrations for adding and
|
17
|
+
# removing columns and indexes required by Hyrarchy.
|
18
|
+
module Migrations
|
19
|
+
def add_hierarchy(table, options = {})
|
20
|
+
convert = options.delete(:convert)
|
21
|
+
unless options.empty?
|
22
|
+
raise(ArgumentError, "unknown keys: #{options.keys.join(', ')}")
|
23
|
+
end
|
24
|
+
|
25
|
+
case convert
|
26
|
+
when :awesome_nested_set
|
27
|
+
remove_column table, :lft
|
28
|
+
remove_column table, :rgt
|
29
|
+
when '', nil
|
30
|
+
else
|
31
|
+
raise(ArgumentError, "don't know how to convert hierarchy from #{convert}")
|
32
|
+
end
|
33
|
+
|
34
|
+
add_column table, :lft, :float
|
35
|
+
add_column table, :rgt, :float
|
36
|
+
add_column table, :lft_numer, :integer
|
37
|
+
add_column table, :lft_denom, :integer
|
38
|
+
add_column table, :parent_id, :integer unless convert == :awesome_nested_set
|
39
|
+
add_index table, :lft
|
40
|
+
add_index table, [:lft_numer, :lft_denom], :unique => true
|
41
|
+
add_index table, :parent_id
|
42
|
+
end
|
43
|
+
|
44
|
+
def remove_hierarchy(table, options = {})
|
45
|
+
convert = options.delete(:convert)
|
46
|
+
unless options.empty?
|
47
|
+
raise(ArgumentError, "unknown keys: #{options.keys.join(', ')}")
|
48
|
+
end
|
49
|
+
|
50
|
+
remove_column table, :lft
|
51
|
+
remove_column table, :rgt
|
52
|
+
remove_column table, :lft_numer
|
53
|
+
remove_column table, :lft_denom
|
54
|
+
remove_column table, :parent_id, :integer unless convert == :awesome_nested_set
|
55
|
+
|
56
|
+
case convert
|
57
|
+
when :awesome_nested_set
|
58
|
+
add_column table, :lft, :integer
|
59
|
+
add_column table, :rgt, :integer
|
60
|
+
when '', nil
|
61
|
+
else
|
62
|
+
raise(ArgumentError, "don't know how to convert hierarchy to #{convert}")
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
module IsHierarchic
|
68
|
+
# Declares that a model represents hierarchic data. Adds a has_many
|
69
|
+
# association for instances' children, and a named scope for the model's
|
70
|
+
# root nodes (called +roots+).
|
71
|
+
def is_hierarchic
|
72
|
+
extend ClassMethods
|
73
|
+
include InstanceMethods
|
74
|
+
|
75
|
+
has_many :children,
|
76
|
+
:foreign_key => 'parent_id',
|
77
|
+
:order => 'rgt DESC, lft',
|
78
|
+
:class_name => self.to_s,
|
79
|
+
:dependent => :destroy
|
80
|
+
|
81
|
+
before_save :set_encoded_paths
|
82
|
+
before_save :set_parent_id
|
83
|
+
after_destroy :mark_path_free
|
84
|
+
|
85
|
+
named_scope :roots,
|
86
|
+
:conditions => { :parent_id => nil },
|
87
|
+
:order => 'rgt DESC, lft'
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# These private methods are available to model classes that have been
|
92
|
+
# declared is_hierarchic. They're used internally and aren't intended to be
|
93
|
+
# used by application developers.
|
94
|
+
module ClassMethods # :nodoc:
|
95
|
+
include Hyrarchy::AwesomeNestedSetCompatibility::ClassMethods
|
96
|
+
|
97
|
+
private
|
98
|
+
|
99
|
+
# Returns an array of unused child paths beneath +parent_path+.
|
100
|
+
def free_child_paths(parent_path)
|
101
|
+
@@free_child_paths ||= {}
|
102
|
+
@@free_child_paths[parent_path] ||= []
|
103
|
+
end
|
104
|
+
|
105
|
+
# Stores +path+ in the arrays of free child paths.
|
106
|
+
def child_path_is_free(path)
|
107
|
+
parent_path = path.parent(false)
|
108
|
+
free_child_paths(parent_path) << path
|
109
|
+
free_child_paths(parent_path).sort!
|
110
|
+
end
|
111
|
+
|
112
|
+
# Removes all paths from the array of free child paths for +parent_path+.
|
113
|
+
def reset_free_child_paths(parent_path)
|
114
|
+
free_child_paths(parent_path).clear
|
115
|
+
end
|
116
|
+
|
117
|
+
# Removes all paths from the array of free child paths.
|
118
|
+
def reset_all_free_child_paths
|
119
|
+
@@free_child_paths = {}
|
120
|
+
end
|
121
|
+
|
122
|
+
# Finds the first unused child path beneath +parent_path+.
|
123
|
+
def next_child_encoded_path(parent_path)
|
124
|
+
p = free_child_paths(parent_path).shift || parent_path.first_child
|
125
|
+
while true do
|
126
|
+
if exists?(:lft_numer => p.numerator, :lft_denom => p.denominator)
|
127
|
+
p = parent_path.mediant(p)
|
128
|
+
else
|
129
|
+
if free_child_paths(parent_path).empty?
|
130
|
+
child_path_is_free(parent_path.mediant(p))
|
131
|
+
end
|
132
|
+
return p
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
# Returns the node with the specified encoded path.
|
138
|
+
def find_by_encoded_path(p)
|
139
|
+
find(:first, :conditions => {
|
140
|
+
:lft_numer => p.numerator,
|
141
|
+
:lft_denom => p.denominator
|
142
|
+
})
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
# These methods are available to instances of models that have been declared
|
147
|
+
# is_hierarchic.
|
148
|
+
module InstanceMethods
|
149
|
+
include Hyrarchy::AwesomeNestedSetCompatibility::InstanceMethods
|
150
|
+
|
151
|
+
# Returns this node's parent, or +nil+ if this is a root node.
|
152
|
+
def parent
|
153
|
+
return @new_parent if @new_parent
|
154
|
+
p = encoded_path.parent
|
155
|
+
return nil if p.nil?
|
156
|
+
self.class.send(:find_by_encoded_path, p)
|
157
|
+
end
|
158
|
+
|
159
|
+
# Sets this node's parent. To make this node a root node, set its parent to
|
160
|
+
# +nil+.
|
161
|
+
def parent=(other)
|
162
|
+
@make_root = false
|
163
|
+
if other.nil?
|
164
|
+
@new_parent = nil
|
165
|
+
@make_root = true
|
166
|
+
elsif encoded_path && other.encoded_path == (encoded_path.parent rescue nil)
|
167
|
+
@new_parent = nil
|
168
|
+
else
|
169
|
+
@new_parent = other
|
170
|
+
end
|
171
|
+
other
|
172
|
+
end
|
173
|
+
|
174
|
+
# Returns an array of this node's descendants: its children, grandchildren,
|
175
|
+
# and so on. The array returned by this method is a named scope.
|
176
|
+
def descendants
|
177
|
+
cached[:descendants] ||=
|
178
|
+
self_and_descendants.scoped :conditions => "id <> #{id}"
|
179
|
+
end
|
180
|
+
|
181
|
+
# Returns an array of this node's ancestors--its parent, grandparent, and
|
182
|
+
# so on--ordered from parent to root. The array returned by this method is
|
183
|
+
# a has_many association, so you can do things like this:
|
184
|
+
#
|
185
|
+
# node.ancestors.find(:all, :conditions => { ... })
|
186
|
+
#
|
187
|
+
def ancestors(with_self = false)
|
188
|
+
cache_key = with_self ? :self_and_ancestors : :ancestors
|
189
|
+
return cached[cache_key] if cached[cache_key]
|
190
|
+
|
191
|
+
paths = []
|
192
|
+
path = with_self ? encoded_path : encoded_path.parent
|
193
|
+
while path do
|
194
|
+
paths << path
|
195
|
+
path = path.parent
|
196
|
+
end
|
197
|
+
|
198
|
+
cached[cache_key] = CollectionProxy.new(
|
199
|
+
self,
|
200
|
+
cache_key,
|
201
|
+
:conditions => paths.empty? ? "id <> id" : [
|
202
|
+
paths.collect {|p| "(lft_numer = ? AND lft_denom = ?)"}.join(" OR "),
|
203
|
+
*(paths.collect {|p| [p.numerator, p.denominator]}.flatten)
|
204
|
+
],
|
205
|
+
:order => 'rgt DESC, lft'
|
206
|
+
)
|
207
|
+
end
|
208
|
+
|
209
|
+
# Returns the root node related to this node, or nil if this node is a root
|
210
|
+
# node.
|
211
|
+
def root
|
212
|
+
return cached[:root] if cached[:root]
|
213
|
+
|
214
|
+
path = encoded_path.parent
|
215
|
+
while path do
|
216
|
+
parent = path.parent
|
217
|
+
break if parent.nil?
|
218
|
+
path = parent
|
219
|
+
end
|
220
|
+
|
221
|
+
if path
|
222
|
+
self.class.send :find_by_encoded_path, path
|
223
|
+
else
|
224
|
+
nil
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
# Returns the number of nodes between this one and the top of the tree.
|
229
|
+
def depth
|
230
|
+
encoded_path.depth - 1
|
231
|
+
end
|
232
|
+
|
233
|
+
# Overrides ActiveRecord's reload method to clear cached scopes and ad hoc
|
234
|
+
# associations.
|
235
|
+
def reload(options = nil) # :nodoc:
|
236
|
+
@cached = {}
|
237
|
+
super
|
238
|
+
end
|
239
|
+
|
240
|
+
protected
|
241
|
+
|
242
|
+
# Sets the node's encoded path, updating all relevant database columns to
|
243
|
+
# match.
|
244
|
+
def encoded_path=(r) # :nodoc:
|
245
|
+
@cached = {}
|
246
|
+
if r.nil?
|
247
|
+
self.lft_numer = nil
|
248
|
+
self.lft_denom = nil
|
249
|
+
self.lft = nil
|
250
|
+
self.rgt = nil
|
251
|
+
else
|
252
|
+
self.lft_numer = r.numerator
|
253
|
+
self.lft_denom = r.denominator
|
254
|
+
self.lft = r.to_f
|
255
|
+
self.rgt = encoded_path.next_farey_fraction.to_f
|
256
|
+
end
|
257
|
+
r
|
258
|
+
end
|
259
|
+
|
260
|
+
# Returns the node's encoded path (its rational left value).
|
261
|
+
def encoded_path # :nodoc:
|
262
|
+
return nil if lft_numer.nil? || lft_denom.nil?
|
263
|
+
Hyrarchy::EncodedPath(lft_numer, lft_denom)
|
264
|
+
end
|
265
|
+
|
266
|
+
# Returns a hash for caching scopes and ad hoc associations.
|
267
|
+
def cached # :nodoc:
|
268
|
+
@cached ||= {}
|
269
|
+
end
|
270
|
+
|
271
|
+
private
|
272
|
+
|
273
|
+
# before_save callback to ensure that this node's encoded path is a child
|
274
|
+
# of its parent, and that its descendants' paths are updated if this node
|
275
|
+
# has moved.
|
276
|
+
def set_encoded_paths # :nodoc:
|
277
|
+
p = nil
|
278
|
+
self.lft_numer = self.lft_denom = nil if @make_root
|
279
|
+
|
280
|
+
if @new_parent.nil?
|
281
|
+
if lft_numer.nil? || lft_denom.nil?
|
282
|
+
p = Hyrarchy::EncodedPath::ROOT
|
283
|
+
end
|
284
|
+
else
|
285
|
+
p = @new_parent.encoded_path
|
286
|
+
end
|
287
|
+
|
288
|
+
if p
|
289
|
+
new_path = self.class.send(:next_child_encoded_path, p)
|
290
|
+
if encoded_path != new_path
|
291
|
+
self.class.send(:reset_free_child_paths, encoded_path)
|
292
|
+
self.encoded_path = new_path
|
293
|
+
children.each do |c|
|
294
|
+
c.parent = self
|
295
|
+
c.save!
|
296
|
+
end
|
297
|
+
end
|
298
|
+
end
|
299
|
+
|
300
|
+
true
|
301
|
+
end
|
302
|
+
|
303
|
+
# before_save callback to ensure that this node's parent_id attribute
|
304
|
+
# agrees with its encoded path.
|
305
|
+
def set_parent_id # :nodoc:
|
306
|
+
parent = self.class.send(:find_by_encoded_path, encoded_path.parent(false))
|
307
|
+
self.parent_id = parent ? parent.id : nil
|
308
|
+
true
|
309
|
+
end
|
310
|
+
|
311
|
+
# after_destroy callback to add this node's encoded path to its parent's
|
312
|
+
# list of available child paths.
|
313
|
+
def mark_path_free # :nodoc:
|
314
|
+
self.class.send(:child_path_is_free, encoded_path)
|
315
|
+
end
|
316
|
+
end
|
317
|
+
end
|
@@ -0,0 +1,295 @@
|
|
1
|
+
module Hyrarchy
|
2
|
+
module AwesomeNestedSetCompatibility
|
3
|
+
module ClassMethods
|
4
|
+
# Returns the first root node.
|
5
|
+
def root
|
6
|
+
roots.first
|
7
|
+
end
|
8
|
+
|
9
|
+
# Returns true if the model's left and right values are valid, and all
|
10
|
+
# root nodes have no ancestors.
|
11
|
+
def valid?
|
12
|
+
left_and_rights_valid? && all_roots_valid?
|
13
|
+
end
|
14
|
+
|
15
|
+
# Returns true if the model's left and right values match the parent_id
|
16
|
+
# attributes.
|
17
|
+
def left_and_rights_valid?
|
18
|
+
# Load all nodes and index them by ID so we can leave the database
|
19
|
+
# alone.
|
20
|
+
nodes = connection.select_all("SELECT id, lft_numer, lft_denom, parent_id FROM #{quoted_table_name}")
|
21
|
+
nodes_by_id = {}
|
22
|
+
nodes.each do |node|
|
23
|
+
node['id'] = node['id'].to_i
|
24
|
+
node['encoded_path'] = Hyrarchy::EncodedPath(node['lft_numer'].to_i, node['lft_denom'].to_i)
|
25
|
+
node['parent_id'] = node['parent_id'] ? node['parent_id'].to_i : nil
|
26
|
+
nodes_by_id[node['id']] = node
|
27
|
+
end
|
28
|
+
# Check to see if the structure defined by the nodes' encoded paths
|
29
|
+
# matches the structure defined by their parent_id attributes.
|
30
|
+
nodes.all? do |node|
|
31
|
+
if node['parent_id'].nil?
|
32
|
+
node['encoded_path'].parent == nil rescue false
|
33
|
+
else
|
34
|
+
parent = nodes_by_id[node['parent_id']]
|
35
|
+
parent && node['encoded_path'].parent == parent['encoded_path']
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# Always returns true. This method exists solely for compatibility with
|
41
|
+
# awesome_nested_set; the test it performs doesn't apply to Hyrarchy.
|
42
|
+
def no_duplicates_for_columns?
|
43
|
+
true
|
44
|
+
end
|
45
|
+
|
46
|
+
# Returns true if all roots have no ancestors.
|
47
|
+
def all_roots_valid?
|
48
|
+
each_root_valid?(roots)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Returns true if all of the nodes in +roots_to_validate+ have no
|
52
|
+
# ancestors.
|
53
|
+
def each_root_valid?(roots_to_validate)
|
54
|
+
roots_to_validate.all? {|r| r.root?}
|
55
|
+
end
|
56
|
+
|
57
|
+
# Rebuilds the model's hierarchy attributes based on the parent_id
|
58
|
+
# attributes.
|
59
|
+
def rebuild!
|
60
|
+
return true if (valid? rescue false)
|
61
|
+
|
62
|
+
update_all("lft = id, rgt = id, lft_numer = id, lft_denom = id")
|
63
|
+
reset_all_free_child_paths
|
64
|
+
paths_by_id = {}
|
65
|
+
order_by = columns_hash['created_at'] ? :created_at : :id
|
66
|
+
|
67
|
+
nodes = roots :order => order_by
|
68
|
+
until nodes.empty? do
|
69
|
+
nodes.each do |node|
|
70
|
+
parent_path = paths_by_id[node.parent_id] || Hyrarchy::EncodedPath::ROOT
|
71
|
+
node.send(:encoded_path=, next_child_encoded_path(parent_path))
|
72
|
+
node.send(:create_or_update_without_callbacks) || raise(RecordNotSaved)
|
73
|
+
paths_by_id[node.id] = node.send(:encoded_path)
|
74
|
+
end
|
75
|
+
node_ids = nodes.collect {|n| n.id}
|
76
|
+
nodes = find(:all, :conditions => { :parent_id => node_ids }, :order => order_by)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
module InstanceMethods
|
82
|
+
# Returns this node's left value. Records that haven't yet been saved
|
83
|
+
# won't have left values.
|
84
|
+
def left
|
85
|
+
encoded_path
|
86
|
+
end
|
87
|
+
|
88
|
+
# Returns this node's left value. Records that haven't yet been saved
|
89
|
+
# won't have right values.
|
90
|
+
def right
|
91
|
+
encoded_path && encoded_path.next_farey_fraction
|
92
|
+
end
|
93
|
+
|
94
|
+
# Returns true if this is a root node.
|
95
|
+
def root?
|
96
|
+
(encoded_path.nil? || depth == 0 || @make_root) && !@new_parent
|
97
|
+
end
|
98
|
+
|
99
|
+
# Returns true if this node has no children.
|
100
|
+
def leaf?
|
101
|
+
children.empty?
|
102
|
+
end
|
103
|
+
|
104
|
+
# Returns true if this node is a child of another node.
|
105
|
+
def child?
|
106
|
+
!root?
|
107
|
+
end
|
108
|
+
|
109
|
+
# Compares two nodes by their left values.
|
110
|
+
def <=>(x)
|
111
|
+
x.left <=> left
|
112
|
+
end
|
113
|
+
|
114
|
+
# Returns an array containing this node and its ancestors, starting with
|
115
|
+
# this node and ending with its root. The array returned by this method
|
116
|
+
# is a has_many association, so you can do things like this:
|
117
|
+
#
|
118
|
+
# node.self_and_ancestors.find(:all, :conditions => { ... })
|
119
|
+
#
|
120
|
+
def self_and_ancestors
|
121
|
+
ancestors(true)
|
122
|
+
end
|
123
|
+
|
124
|
+
# Returns an array containing this node and its siblings. The array
|
125
|
+
# returned by this method is a has_many association, so you can do things
|
126
|
+
# like this:
|
127
|
+
#
|
128
|
+
# node.self_and_siblings.find(:all, :conditions => { ... })
|
129
|
+
#
|
130
|
+
def self_and_siblings
|
131
|
+
siblings(true)
|
132
|
+
end
|
133
|
+
|
134
|
+
# Returns an array containing this node's siblings. The array returned by
|
135
|
+
# this method is a has_many association, so you can do things like this:
|
136
|
+
#
|
137
|
+
# node.siblings.find(:all, :conditions => { ... })
|
138
|
+
#
|
139
|
+
def siblings(with_self = false)
|
140
|
+
cache_key = with_self ? :self_and_siblings : :siblings
|
141
|
+
return cached[cache_key] if cached[cache_key]
|
142
|
+
|
143
|
+
if with_self
|
144
|
+
conditions = { :parent_id => parent_id }
|
145
|
+
else
|
146
|
+
conditions = ["parent_id #{parent_id.nil? ? 'IS' : '='} ? AND id <> ?",
|
147
|
+
parent_id, id]
|
148
|
+
end
|
149
|
+
|
150
|
+
cached[cache_key] = self.class.scoped(
|
151
|
+
:conditions => conditions,
|
152
|
+
:order => 'rgt DESC, lft'
|
153
|
+
)
|
154
|
+
end
|
155
|
+
|
156
|
+
# Returns an array containing this node's childless descendants. The
|
157
|
+
# array returned by this method is a named scope.
|
158
|
+
def leaves
|
159
|
+
cached[:leaves] ||= descendants.scoped :conditions => "NOT EXISTS (
|
160
|
+
SELECT * FROM #{self.class.quoted_table_name} tt
|
161
|
+
WHERE tt.parent_id = #{self.class.quoted_table_name}.id
|
162
|
+
)"
|
163
|
+
end
|
164
|
+
|
165
|
+
# Alias for depth.
|
166
|
+
def level
|
167
|
+
depth
|
168
|
+
end
|
169
|
+
|
170
|
+
# Returns an array of this node and its descendants: its children,
|
171
|
+
# grandchildren, and so on. The array returned by this method is a
|
172
|
+
# has_many association, so you can do things like this:
|
173
|
+
#
|
174
|
+
# node.self_and_descendants.find(:all, :conditions => { ... })
|
175
|
+
#
|
176
|
+
def self_and_descendants
|
177
|
+
cached[:self_and_descendants] ||= CollectionProxy.new(
|
178
|
+
self,
|
179
|
+
:descendants,
|
180
|
+
:conditions => { :lft => (lft - FLOAT_FUDGE_FACTOR)..(rgt + FLOAT_FUDGE_FACTOR) },
|
181
|
+
:order => 'rgt DESC, lft',
|
182
|
+
# The query conditions intentionally load extra records that aren't
|
183
|
+
# descendants to account for floating point imprecision. This
|
184
|
+
# procedure removes the extra records.
|
185
|
+
:after => Proc.new do |records|
|
186
|
+
r = encoded_path.next_farey_fraction
|
187
|
+
records.delete_if do |n|
|
188
|
+
n.encoded_path < encoded_path || n.encoded_path >= r
|
189
|
+
end
|
190
|
+
end,
|
191
|
+
# The regular count method doesn't work because of the fudge factor
|
192
|
+
# in the conditions. This procedure uses the length of the records
|
193
|
+
# array if it's been loaded. Otherwise it does a raw SQL query (to
|
194
|
+
# avoid the expense of instantiating a bunch of ActiveRecord objects)
|
195
|
+
# and prunes the results in the same manner as the :after procedure.
|
196
|
+
:count => Proc.new do
|
197
|
+
if descendants.loaded?
|
198
|
+
descendants.length
|
199
|
+
else
|
200
|
+
rows = self.class.connection.select_all("
|
201
|
+
SELECT lft_numer, lft_denom
|
202
|
+
FROM #{self.class.quoted_table_name}
|
203
|
+
WHERE #{descendants.conditions}")
|
204
|
+
r = encoded_path.next_farey_fraction
|
205
|
+
rows.delete_if do |row|
|
206
|
+
p = Hyrarchy::EncodedPath(
|
207
|
+
row['lft_numer'].to_i,
|
208
|
+
row['lft_denom'].to_i)
|
209
|
+
p < encoded_path || p >= r
|
210
|
+
end
|
211
|
+
rows.length
|
212
|
+
end
|
213
|
+
end
|
214
|
+
)
|
215
|
+
end
|
216
|
+
|
217
|
+
# Returns true if this node is a descendant of +other+.
|
218
|
+
def is_descendant_of?(other)
|
219
|
+
left > other.left && left <= other.right
|
220
|
+
end
|
221
|
+
|
222
|
+
# Returns true if this node is a descendant of +other+, or if this node
|
223
|
+
# is +other+.
|
224
|
+
def is_or_is_descendant_of?(other)
|
225
|
+
left >= other.left && left <= other.right
|
226
|
+
end
|
227
|
+
|
228
|
+
# Returns true if this node is an ancestor of +other+.
|
229
|
+
def is_ancestor_of?(other)
|
230
|
+
other.left > left && other.left <= right
|
231
|
+
end
|
232
|
+
|
233
|
+
# Returns true if this node is an ancestor of +other+, or if this node is
|
234
|
+
# +other+.
|
235
|
+
def is_or_is_ancestor_of?(other)
|
236
|
+
other.left >= left && other.left <= right
|
237
|
+
end
|
238
|
+
|
239
|
+
# Always returns true. This method exists solely for compatibility with
|
240
|
+
# awesome_nested_set; Hyrarchy doesn't support scoping (but maybe it will
|
241
|
+
# some day).
|
242
|
+
def same_scope?(other)
|
243
|
+
true
|
244
|
+
end
|
245
|
+
|
246
|
+
def left_sibling # :nodoc:
|
247
|
+
raise NotImplementedError, "awesome_nested_set's left_sibling method isn't implemented in this version of Hyrarchy"
|
248
|
+
end
|
249
|
+
|
250
|
+
def right_sibling # :nodoc:
|
251
|
+
raise NotImplementedError, "awesome_nested_set's right_sibling method isn't implemented in this version of Hyrarchy"
|
252
|
+
end
|
253
|
+
|
254
|
+
def move_left # :nodoc:
|
255
|
+
raise NotImplementedError, "awesome_nested_set's move_left method isn't implemented in this version of Hyrarchy"
|
256
|
+
end
|
257
|
+
|
258
|
+
def move_right # :nodoc:
|
259
|
+
raise NotImplementedError, "awesome_nested_set's move_right method isn't implemented in this version of Hyrarchy"
|
260
|
+
end
|
261
|
+
|
262
|
+
def move_to_left_of(other) # :nodoc:
|
263
|
+
raise NotImplementedError, "awesome_nested_set's move_to_left_of method isn't implemented in this version of Hyrarchy"
|
264
|
+
end
|
265
|
+
|
266
|
+
def move_to_right_of(other) # :nodoc:
|
267
|
+
raise NotImplementedError, "awesome_nested_set's move_to_right_of method isn't implemented in this version of Hyrarchy"
|
268
|
+
end
|
269
|
+
|
270
|
+
# Sets this node's parent to +node+ and calls save!.
|
271
|
+
def move_to_child_of(node)
|
272
|
+
node = self.class.find(node)
|
273
|
+
self.parent = node
|
274
|
+
save!
|
275
|
+
end
|
276
|
+
|
277
|
+
# Makes this node a root node and calls save!.
|
278
|
+
def move_to_root
|
279
|
+
self.parent = nil
|
280
|
+
save!
|
281
|
+
end
|
282
|
+
|
283
|
+
def move_possible?(target) # :nodoc:
|
284
|
+
raise NotImplementedError, "awesome_nested_set's move_possible? method isn't implemented in this version of Hyrarchy"
|
285
|
+
end
|
286
|
+
|
287
|
+
# Returns a textual representation of this node and its descendants.
|
288
|
+
def to_text
|
289
|
+
self_and_descendants.map do |node|
|
290
|
+
"#{'*'*(node.depth+1)} #{node.id} #{node.to_s} (#{node.parent_id}, #{node.left}, #{node.right})"
|
291
|
+
end.join("\n")
|
292
|
+
end
|
293
|
+
end
|
294
|
+
end
|
295
|
+
end
|
@@ -0,0 +1,65 @@
|
|
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
|
+
reflection = ActiveRecord::Base.create_reflection(
|
11
|
+
:has_many, name, options.merge(:class_name => owner.class.to_s), owner.class)
|
12
|
+
super(owner, reflection)
|
13
|
+
end
|
14
|
+
|
15
|
+
# This is ripped right from the construct_sql method in HasManyAssociation,
|
16
|
+
# but the foreign key condition has been removed.
|
17
|
+
def construct_sql
|
18
|
+
if @reflection.options[:finder_sql]
|
19
|
+
@finder_sql = interpolate_sql(@reflection.options[:finder_sql])
|
20
|
+
else
|
21
|
+
@finder_sql = conditions
|
22
|
+
end
|
23
|
+
|
24
|
+
if @reflection.options[:counter_sql]
|
25
|
+
@counter_sql = interpolate_sql(@reflection.options[:counter_sql])
|
26
|
+
elsif @reflection.options[:finder_sql]
|
27
|
+
# replace the SELECT clause with COUNT(*), preserving any hints within /* ... */
|
28
|
+
@reflection.options[:counter_sql] = @reflection.options[:finder_sql].sub(/SELECT (\/\*.*?\*\/ )?(.*)\bFROM\b/im) { "SELECT #{$1}COUNT(*) FROM" }
|
29
|
+
@counter_sql = interpolate_sql(@reflection.options[:counter_sql])
|
30
|
+
else
|
31
|
+
@counter_sql = @finder_sql
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Overrides find to run the association's +after+ procedure on the results.
|
36
|
+
def find(*args)
|
37
|
+
records = super
|
38
|
+
@after.call(records) if @after
|
39
|
+
records
|
40
|
+
end
|
41
|
+
|
42
|
+
# Overrides count to run the association's +count+ procedure, with caching.
|
43
|
+
def count
|
44
|
+
if @count
|
45
|
+
if @count.respond_to?(:call)
|
46
|
+
@count = @count.call
|
47
|
+
else
|
48
|
+
@count
|
49
|
+
end
|
50
|
+
else
|
51
|
+
super
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
protected
|
56
|
+
|
57
|
+
# Overrides find_target to run the association's +after+ procedure on the
|
58
|
+
# results.
|
59
|
+
def find_target
|
60
|
+
records = super
|
61
|
+
@after.call(records) if @after
|
62
|
+
records
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,101 @@
|
|
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
|
+
# Finds the mediant of this fraction and +other+.
|
59
|
+
def mediant(other)
|
60
|
+
Hyrarchy::EncodedPath(
|
61
|
+
numerator + other.numerator,
|
62
|
+
denominator + other.denominator)
|
63
|
+
end
|
64
|
+
|
65
|
+
# Returns the fraction immediately after this one in the Farey sequence
|
66
|
+
# whose order is this fraction's denominator. This is the find-neighbors
|
67
|
+
# algorithm from "Rounding rational numbers using Farey/Cauchy sequence" by
|
68
|
+
# Wim Lewis: http://www.hhhh.org/wiml/proj/farey
|
69
|
+
def next_farey_fraction
|
70
|
+
# Handle the special case of the last fraction.
|
71
|
+
return nil if self == Rational(1, 1)
|
72
|
+
# Compute the modular multiplicative inverses of the numerator and
|
73
|
+
# denominator using an iterative extended Euclidean algorithm. These
|
74
|
+
# inverses are the denominator and negative numerator of the fraction
|
75
|
+
# preceding this one, modulo the numerator and denominator of this
|
76
|
+
# fraction.
|
77
|
+
a, b = [numerator, denominator]
|
78
|
+
x, lastx, y, lasty = [0, 1, 1, 0]
|
79
|
+
while b != 0
|
80
|
+
a, b, q = [b, a % b, a / b]
|
81
|
+
x, lastx = [lastx - q * x, x]
|
82
|
+
y, lasty = [lasty - q * y, y]
|
83
|
+
end
|
84
|
+
qL, pL = [lastx, -lasty]
|
85
|
+
# Find the numerator and denominator of the fraction following this one
|
86
|
+
# using the mediant relationship between it, this fraction, and the
|
87
|
+
# preceding fraction. The modulo ambiguity is resolved by brute force,
|
88
|
+
# which is probably not the smartest way to do it, but it's fast enough.
|
89
|
+
i = 0
|
90
|
+
while true do
|
91
|
+
a = pL + numerator * i
|
92
|
+
b = qL + denominator * i
|
93
|
+
if (numerator * b - denominator * a == 1) &&
|
94
|
+
(Rational(numerator - a, denominator - b).denominator <= denominator)
|
95
|
+
return Hyrarchy::EncodedPath(numerator - a, denominator - b)
|
96
|
+
end
|
97
|
+
i += 1
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
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
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'test_helper')
|
2
|
+
|
3
|
+
class EncodedPathTests < Test::Unit::TestCase
|
4
|
+
def setup
|
5
|
+
@path = Hyrarchy::EncodedPath(5, 7)
|
6
|
+
end
|
7
|
+
|
8
|
+
def test_next_farey_fraction
|
9
|
+
assert_equal(Rational(3, 4), @path.send(:next_farey_fraction))
|
10
|
+
end
|
11
|
+
|
12
|
+
def test_mediant
|
13
|
+
assert_equal(Rational(15, 20), @path.send(:mediant, Rational(10, 13)))
|
14
|
+
end
|
15
|
+
|
16
|
+
def test_parent
|
17
|
+
assert_equal(Rational(2, 3), @path.parent)
|
18
|
+
end
|
19
|
+
|
20
|
+
def test_depth
|
21
|
+
assert_equal(3, @path.depth)
|
22
|
+
end
|
23
|
+
|
24
|
+
def test_first_child
|
25
|
+
assert_equal(Rational(8, 11), @path.first_child)
|
26
|
+
end
|
27
|
+
|
28
|
+
def test_next_sibling
|
29
|
+
assert_equal(Rational(7, 10), @path.next_sibling)
|
30
|
+
end
|
31
|
+
end
|
data/test/test_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: DanaDanger-hyrarchy
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: "0.1"
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Dana Danger
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2008-12-15 00:00:00 -08:00
|
13
|
+
default_executable:
|
14
|
+
dependencies: []
|
15
|
+
|
16
|
+
description:
|
17
|
+
email:
|
18
|
+
executables: []
|
19
|
+
|
20
|
+
extensions: []
|
21
|
+
|
22
|
+
extra_rdoc_files:
|
23
|
+
- README.rdoc
|
24
|
+
- LICENSE
|
25
|
+
files:
|
26
|
+
- lib/hyrarchy.rb
|
27
|
+
- lib/hyrarchy/collection_proxy.rb
|
28
|
+
- lib/hyrarchy/encoded_path.rb
|
29
|
+
- lib/hyrarchy/awesome_nested_set_compatibility.rb
|
30
|
+
- rails_plugin/init.rb
|
31
|
+
- README.rdoc
|
32
|
+
- spec/create_nodes_table.rb
|
33
|
+
- spec/database.yml
|
34
|
+
- spec/hyrarchy_spec.rb
|
35
|
+
- spec/spec_helper.rb
|
36
|
+
- test/encoded_path_test.rb
|
37
|
+
- test/test_helper.rb
|
38
|
+
- LICENSE
|
39
|
+
has_rdoc: true
|
40
|
+
homepage: http://github.com/DanaDanger/hyrarchy
|
41
|
+
post_install_message:
|
42
|
+
rdoc_options:
|
43
|
+
- --all
|
44
|
+
- --inline-source
|
45
|
+
- --line-numbers
|
46
|
+
require_paths:
|
47
|
+
- lib
|
48
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
49
|
+
requirements:
|
50
|
+
- - ">="
|
51
|
+
- !ruby/object:Gem::Version
|
52
|
+
version: "0"
|
53
|
+
version:
|
54
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
55
|
+
requirements:
|
56
|
+
- - ">="
|
57
|
+
- !ruby/object:Gem::Version
|
58
|
+
version: "0"
|
59
|
+
version:
|
60
|
+
requirements: []
|
61
|
+
|
62
|
+
rubyforge_project:
|
63
|
+
rubygems_version: 1.2.0
|
64
|
+
signing_key:
|
65
|
+
specification_version: 2
|
66
|
+
summary: A gem and Rails plugin for working with hierarchic data.
|
67
|
+
test_files:
|
68
|
+
- spec/create_nodes_table.rb
|
69
|
+
- spec/database.yml
|
70
|
+
- spec/hyrarchy_spec.rb
|
71
|
+
- spec/spec_helper.rb
|
72
|
+
- test/encoded_path_test.rb
|
73
|
+
- test/test_helper.rb
|