make_like_a_tree 1.0.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/.gemtest +0 -0
- data/History.txt +6 -0
- data/Manifest.txt +7 -0
- data/README.txt +97 -0
- data/Rakefile +15 -0
- data/init.rb +2 -0
- data/lib/make_like_a_tree.rb +468 -0
- data/test/test_ordered_tree.rb +474 -0
- metadata +110 -0
data/.gemtest
ADDED
File without changes
|
data/History.txt
ADDED
data/Manifest.txt
ADDED
data/README.txt
ADDED
@@ -0,0 +1,97 @@
|
|
1
|
+
= make_like_a_tree
|
2
|
+
|
3
|
+
http://github.com/julik/make_like_a_tree
|
4
|
+
|
5
|
+
== DESCRIPTION:
|
6
|
+
|
7
|
+
Implement orderable trees in ActiveRecord using the nested set model, with multiple roots and scoping, and most importantly user-defined
|
8
|
+
ordering of subtrees. Fetches preordered trees in one go, updates are write-heavy.
|
9
|
+
|
10
|
+
This is a substantially butchered-up version/offspring of acts_as_threaded. The main additional perk is the ability
|
11
|
+
to reorder nodes, which are always fetched ordered. Example:
|
12
|
+
|
13
|
+
root = Folder.create! :name => "Main folder"
|
14
|
+
subfolder_1 = Folder.create! :name => "Subfolder", :parent_id => root.id
|
15
|
+
subfolder_2 = Folder.create! :name => "Another subfolder", :parent_id => root.id
|
16
|
+
|
17
|
+
subfolder_2.move_to_top # just like acts_as_list but nestedly awesome
|
18
|
+
root.all_children # => [subfolder_2, subfolder_1]
|
19
|
+
|
20
|
+
See the rdocs for examples the method names. It also inherits the awesome properties of acts_as_threaded, namely
|
21
|
+
materialized depth, root_id and parent_id values on each object which are updated when nodes get moved.
|
22
|
+
|
23
|
+
Thanks to the authors of acts_as_threaded, awesome_nested_set, better_nested_set and all the others for inspiration.
|
24
|
+
|
25
|
+
|
26
|
+
== FEATURES/PROBLEMS:
|
27
|
+
|
28
|
+
* Currently there is no clean way to change the column you scope on
|
29
|
+
* Use create with parent_id set to the parent id (obvious, but somehow blocked in awesome_nested_set)
|
30
|
+
* Ugly SQL
|
31
|
+
* The node counts are currently not updated when a node is removed from a subtree and replanted elsewhere,
|
32
|
+
so you cannot rely on (right-left)/2 to get the child count
|
33
|
+
* You cannot replant a node by assigning a new parent_id, add_child needed instead
|
34
|
+
* The table needs to have proper defaults otherwise undefined behavior can happen. Otherwise demons
|
35
|
+
will fly out of your left nostril and make you rewrite the app in inline PHP.
|
36
|
+
|
37
|
+
== SYNOPSIS:
|
38
|
+
|
39
|
+
class NodeOfThatUbiquitousCms < ActiveRecord::Base
|
40
|
+
make_like_a_tree
|
41
|
+
|
42
|
+
# Handy for selects and tree text
|
43
|
+
def indented_name
|
44
|
+
["-" * depth.to_i, name].join
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
== REQUIREMENTS:
|
49
|
+
|
50
|
+
Use the following migration (attention! dangerous defaults ahead!):
|
51
|
+
|
52
|
+
create_table :nodes do |t|
|
53
|
+
# Bookkeeping for threads
|
54
|
+
t.integer :root_id, :default => 0, :null => false
|
55
|
+
t.integer :parent_id, :default => 0, :null => false
|
56
|
+
t.integer :depth, :default => 0, :null => false
|
57
|
+
t.integer :lft, :default => 0, :null => false
|
58
|
+
t.integer :rgt, :default => 0, :null => false
|
59
|
+
end
|
60
|
+
|
61
|
+
== INSTALL:
|
62
|
+
|
63
|
+
Add a bare init file to your app and there:
|
64
|
+
|
65
|
+
require 'make_like_tree'
|
66
|
+
Julik::MakeLikeTree.bootstrap!
|
67
|
+
|
68
|
+
Or just vendorize it, it has a built-in init.rb. You can also use the
|
69
|
+
plugin without unpacking it, to do so put the following in the config:
|
70
|
+
|
71
|
+
config.gem "make_like_a_tree"
|
72
|
+
config.after_initialize { Julik::MakeLikeTree.bootstrap! }
|
73
|
+
|
74
|
+
== LICENSE:
|
75
|
+
|
76
|
+
(The MIT License)
|
77
|
+
|
78
|
+
Copyright (c) 2009 Julik Tarkhanov <me@julik.nl>
|
79
|
+
|
80
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
81
|
+
a copy of this software and associated documentation files (the
|
82
|
+
'Software'), to deal in the Software without restriction, including
|
83
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
84
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
85
|
+
permit persons to whom the Software is furnished to do so, subject to
|
86
|
+
the following conditions:
|
87
|
+
|
88
|
+
The above copyright notice and this permission notice shall be
|
89
|
+
included in all copies or substantial portions of the Software.
|
90
|
+
|
91
|
+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
92
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
93
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
94
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
95
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
96
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
97
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/Rakefile
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
# -*- ruby -*-
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
require 'hoe'
|
5
|
+
require './lib/make_like_a_tree'
|
6
|
+
|
7
|
+
# Disable spurious warnings when running tests, ActiveMagic cannot stand -w
|
8
|
+
Hoe::RUBY_FLAGS.gsub!(/^-w/, '')
|
9
|
+
|
10
|
+
Hoe.spec('make_like_a_tree') do |p|
|
11
|
+
p.version = Julik::MakeLikeTree::VERSION
|
12
|
+
p.developer('Julik Tarkhanov', 'me@julik.nl')
|
13
|
+
end
|
14
|
+
|
15
|
+
# vim: syntax=Ruby
|
data/init.rb
ADDED
@@ -0,0 +1,468 @@
|
|
1
|
+
module Julik
|
2
|
+
module MakeLikeTree
|
3
|
+
class ImpossibleReparent < RuntimeError
|
4
|
+
end
|
5
|
+
|
6
|
+
VERSION = '1.0.3'
|
7
|
+
DEFAULTS = {
|
8
|
+
:root_column => "root_id",
|
9
|
+
:parent_column => "parent_id",
|
10
|
+
:left_column => "lft",
|
11
|
+
:right_column => "rgt",
|
12
|
+
:depth_column => 'depth',
|
13
|
+
:scope => "(1=1)"
|
14
|
+
}
|
15
|
+
|
16
|
+
def self.included(base) #:nodoc:
|
17
|
+
super
|
18
|
+
base.extend(ClassMethods)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Injects the module into ActiveRecord. Can (and should) be used in config.after_initialize
|
22
|
+
# block of the app
|
23
|
+
def self.bootstrap!
|
24
|
+
::ActiveRecord::Base.send :include, self
|
25
|
+
end
|
26
|
+
|
27
|
+
module ClassMethods
|
28
|
+
# An acts_as_threaded on steroids. Configuration options are:
|
29
|
+
#
|
30
|
+
# * +root_column+ - specifies the column name to use for identifying the root thread, default "root_id"
|
31
|
+
# * +parent_column+ - specifies the column name to use for keeping the position integer, default "parent_id"
|
32
|
+
# * +left_column+ - column name for left boundary data, default "lft"
|
33
|
+
# * +right_column+ - column name for right boundary data, default "rgt"
|
34
|
+
# * +depth+ - column name used to track the depth in the branch, default "depth"
|
35
|
+
# * +scope+ - adds an additional contraint on the threads when searching or updating
|
36
|
+
def make_like_a_tree(options = {})
|
37
|
+
configuration = DEFAULTS.dup.merge(options)
|
38
|
+
|
39
|
+
if configuration[:scope].is_a?(Symbol) && configuration[:scope].to_s !~ /_id$/
|
40
|
+
configuration[:scope] = "#{configuration[:scope]}_id".intern
|
41
|
+
end
|
42
|
+
|
43
|
+
if configuration[:scope].is_a?(Symbol)
|
44
|
+
scope_condition_method = %(
|
45
|
+
def scope_condition
|
46
|
+
if #{configuration[:scope].to_s}.nil?
|
47
|
+
"#{configuration[:scope].to_s} IS NULL"
|
48
|
+
else
|
49
|
+
"#{configuration[:scope].to_s} = \#{#{configuration[:scope].to_s}}"
|
50
|
+
end
|
51
|
+
end
|
52
|
+
)
|
53
|
+
else
|
54
|
+
scope_condition_method = "def scope_condition() \"#{configuration[:scope]}\" end"
|
55
|
+
end
|
56
|
+
|
57
|
+
after_create :apply_parenting_after_create
|
58
|
+
|
59
|
+
|
60
|
+
# before_update :register_parent_id_before_update, :unless => :new_record?
|
61
|
+
# after_update :replant_after_update
|
62
|
+
|
63
|
+
# TODO: refactor for class << self
|
64
|
+
class_eval <<-EOV
|
65
|
+
include Julik::MakeLikeTree::InstanceMethods
|
66
|
+
|
67
|
+
#{scope_condition_method}
|
68
|
+
|
69
|
+
def root_column() "#{configuration[:root_column]}" end
|
70
|
+
def parent_column() "#{configuration[:parent_column]}" end
|
71
|
+
def left_col_name() "#{configuration[:left_column]}" end
|
72
|
+
def right_col_name() "#{configuration[:right_column]}" end
|
73
|
+
def depth_column() "#{configuration[:depth_column]}" end
|
74
|
+
|
75
|
+
EOV
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
module InstanceMethods
|
80
|
+
|
81
|
+
# Move the item to a specific index within the range of it's siblings. Used to reorder lists.
|
82
|
+
# Will cause a cascading update on the neighbouring items and their children, but the update will be scoped
|
83
|
+
def move_to(idx)
|
84
|
+
return false if new_record?
|
85
|
+
|
86
|
+
transaction do
|
87
|
+
# Take a few shortcuts to avoid extra work
|
88
|
+
cur_idx = index_in_parent
|
89
|
+
return true if (cur_idx == idx)
|
90
|
+
|
91
|
+
range = siblings_and_self
|
92
|
+
return true if range.length == 1
|
93
|
+
|
94
|
+
cur_idx = range.index(self)
|
95
|
+
return true if cur_idx == idx
|
96
|
+
|
97
|
+
# Register starting and ending elements
|
98
|
+
start_left, end_right = range[0][left_col_name], range[-1][right_col_name]
|
99
|
+
|
100
|
+
old_range = range.dup
|
101
|
+
|
102
|
+
range.delete_at(cur_idx)
|
103
|
+
range.insert(idx, self)
|
104
|
+
range.compact! # If we inserted something outside of range and created empty slots
|
105
|
+
|
106
|
+
# Now remap segements
|
107
|
+
left_remaps, right_remaps, mini_scopes = [], [], ["(1=0)"]
|
108
|
+
|
109
|
+
# Exhaust the range starting with the last element, determining the remapped offset
|
110
|
+
# based on the width of remaining sets
|
111
|
+
while range.any?
|
112
|
+
e = range.pop
|
113
|
+
|
114
|
+
w = (e[right_col_name] - e[left_col_name])
|
115
|
+
|
116
|
+
# Determine by how many we need to shift the adjacent keys to put this item into place.
|
117
|
+
# On every iteration add 1 (the formal increment in a leaf node)
|
118
|
+
offset_in_range = range.inject(0) do | sum, item_before |
|
119
|
+
sum + item_before[right_col_name] - item_before[left_col_name] + 1
|
120
|
+
end
|
121
|
+
shift = offset_in_range - e[left_col_name] + 1
|
122
|
+
|
123
|
+
# Optimize - do not move nodes that stay in the same place
|
124
|
+
next if shift.zero?
|
125
|
+
|
126
|
+
case_stmt = "#{left_col_name} >= #{e[left_col_name]} AND #{right_col_name} <= #{e[right_col_name]}"
|
127
|
+
|
128
|
+
# Scoping our query by the mini-scope will help us avoid a table scan in some situations
|
129
|
+
mini_scopes << case_stmt
|
130
|
+
|
131
|
+
left_remaps.unshift(
|
132
|
+
"WHEN (#{case_stmt}) THEN (#{left_col_name} + #{shift})"
|
133
|
+
)
|
134
|
+
right_remaps.unshift(
|
135
|
+
"WHEN (#{case_stmt}) THEN (#{right_col_name} + #{shift})"
|
136
|
+
)
|
137
|
+
end
|
138
|
+
|
139
|
+
# If we are not a root node, scope the changes to our subtree only - this will win us some less writes
|
140
|
+
update_condition = root? ? scope_condition : "#{scope_condition} AND #{root_column} = #{self[root_column]}"
|
141
|
+
update_condition << " AND (#{mini_scopes.join(" OR ")})"
|
142
|
+
|
143
|
+
self.class.update_all(
|
144
|
+
"#{left_col_name} = CASE #{left_remaps.join(' ')} ELSE #{left_col_name} END, " +
|
145
|
+
"#{right_col_name} = CASE #{right_remaps.join(' ')} ELSE #{right_col_name} END ",
|
146
|
+
update_condition
|
147
|
+
)
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
# Move the record down in the list (uses move_to)
|
152
|
+
def move_up
|
153
|
+
move_to(index_in_parent - 1)
|
154
|
+
end
|
155
|
+
|
156
|
+
# Move the record up in the list (uses move_to)
|
157
|
+
def move_down
|
158
|
+
move_to(index_in_parent + 1)
|
159
|
+
end
|
160
|
+
|
161
|
+
# Move the record to top of the list (uses move_to)
|
162
|
+
def move_to_top
|
163
|
+
move_to(0)
|
164
|
+
end
|
165
|
+
|
166
|
+
# Move the record to the bottom of the list (uses move_to)
|
167
|
+
def move_to_bottom
|
168
|
+
move_to(-1)
|
169
|
+
end
|
170
|
+
|
171
|
+
# Get the item index in parent. TODO: when the tree is balanced with no orphan counts, just use (rgt-lft)/2
|
172
|
+
def index_in_parent
|
173
|
+
# Fetch the item count of items that have the same root_id and the same parent_id and are lower than me on the indices
|
174
|
+
@index_in_parent ||= self.class.count_by_sql(
|
175
|
+
"SELECT COUNT(id) FROM #{self.class.table_name} WHERE " +
|
176
|
+
"#{right_col_name} < #{self[left_col_name]} AND #{parent_column} = #{self[parent_column]}"
|
177
|
+
)
|
178
|
+
end
|
179
|
+
|
180
|
+
# Override ActiveRecord::Base#reload to blow over all the memoized values
|
181
|
+
def reload(options = nil)
|
182
|
+
@index_in_parent, @is_root, @is_child,
|
183
|
+
@old_parent_id, @rerooted, @child_count = nil, nil, nil, nil, nil, nil
|
184
|
+
super(options)
|
185
|
+
end
|
186
|
+
|
187
|
+
# Returns true is this is a root thread.
|
188
|
+
def root?
|
189
|
+
self[parent_column].to_i.zero?
|
190
|
+
end
|
191
|
+
|
192
|
+
# Returns true is this is a child node. Inverse of root?
|
193
|
+
def child?
|
194
|
+
!root?
|
195
|
+
end
|
196
|
+
|
197
|
+
# Used as an after_create callback to apply the parent_id assignment or create a root node
|
198
|
+
def apply_parenting_after_create
|
199
|
+
reload # Reload to bring in the id
|
200
|
+
assign_default_left_and_right
|
201
|
+
|
202
|
+
transaction do
|
203
|
+
self.save
|
204
|
+
unless self[parent_column].to_i.zero? # will also capture nil
|
205
|
+
# Load the parent
|
206
|
+
parent = self.class.find(self[parent_column])
|
207
|
+
parent.add_child self
|
208
|
+
end
|
209
|
+
end
|
210
|
+
true
|
211
|
+
end
|
212
|
+
|
213
|
+
# Place the item to the appropriate place as a root item
|
214
|
+
def assign_default_left_and_right(with_space_inside = 0)
|
215
|
+
# Make a self root and assign left and right respectively
|
216
|
+
# even if no children are specified
|
217
|
+
self[root_column] = self.id
|
218
|
+
self[left_col_name], self[right_col_name] = get_left_and_right_for(self, with_space_inside)
|
219
|
+
end
|
220
|
+
|
221
|
+
|
222
|
+
# Shortcut for self[depth_column]
|
223
|
+
def level
|
224
|
+
self[depth_column]
|
225
|
+
end
|
226
|
+
|
227
|
+
# Adds a child to this object in the tree. If this object hasn't been initialized,
|
228
|
+
# it gets set up as a root node. Otherwise, this method will update all of the
|
229
|
+
# other elements in the tree and shift them to the right, keeping everything
|
230
|
+
# balanced.
|
231
|
+
def add_child(child)
|
232
|
+
begin
|
233
|
+
add_child!(child)
|
234
|
+
rescue ImpossibleReparent
|
235
|
+
false
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
# Tells you if a reparent might be invalid
|
240
|
+
def child_can_be_added?(child)
|
241
|
+
impossible = (child[root_column] == self[root_column] &&
|
242
|
+
child[left_col_name] < self[left_col_name]) &&
|
243
|
+
(child[right_col_name] > self[right_col_name])
|
244
|
+
|
245
|
+
!impossible
|
246
|
+
end
|
247
|
+
|
248
|
+
# A noisy version of add_child, will raise an ImpossibleReparent if you try to reparent a node onto its indirect child.
|
249
|
+
# Will return false if either of the records is a new record. Will reload both the parent and the child record
|
250
|
+
def add_child!(child)
|
251
|
+
return false if (new_record? || child.new_record?)
|
252
|
+
raise ImpossibleReparent, "Cannot reparent #{child} onto its child node #{self}" unless child_can_be_added?(child)
|
253
|
+
|
254
|
+
k = self.class
|
255
|
+
|
256
|
+
new_left, new_right = determine_range_for_child(child)
|
257
|
+
|
258
|
+
move_by = new_left - child[left_col_name]
|
259
|
+
move_depth_by = (self[depth_column] + 1) - child[depth_column]
|
260
|
+
|
261
|
+
child_occupies = (new_right - new_left) + 1
|
262
|
+
|
263
|
+
transaction do
|
264
|
+
# bring the child and its grandchildren over
|
265
|
+
self.class.update_all(
|
266
|
+
"#{depth_column} = #{depth_column} + #{move_depth_by}," +
|
267
|
+
"#{root_column} = #{self[root_column]}," +
|
268
|
+
"#{left_col_name} = #{left_col_name} + #{move_by}," +
|
269
|
+
"#{right_col_name} = #{right_col_name} + #{move_by}",
|
270
|
+
"#{scope_condition} AND #{left_col_name} >= #{child[left_col_name]} AND #{right_col_name} <= #{child[right_col_name]}" +
|
271
|
+
" AND #{root_column} = #{child[root_column]} AND #{root_column} != 0"
|
272
|
+
)
|
273
|
+
|
274
|
+
# update parent_id on child ONLY
|
275
|
+
self.class.update_all(
|
276
|
+
"#{parent_column} = #{self.id}",
|
277
|
+
"id = #{child.id}"
|
278
|
+
)
|
279
|
+
|
280
|
+
# update myself and upstream to notify we are wider
|
281
|
+
self.class.update_all(
|
282
|
+
"#{right_col_name} = #{right_col_name} + #{child_occupies}",
|
283
|
+
"#{scope_condition} AND #{root_column} = #{self[root_column]} AND (#{depth_column} < #{self[depth_column]} OR id = #{self.id})"
|
284
|
+
)
|
285
|
+
|
286
|
+
# update items to my right AND downstream of them to notify them we are wider. Will shift root items to the right
|
287
|
+
self.class.update_all(
|
288
|
+
"#{left_col_name} = #{left_col_name} + #{child_occupies}, " +
|
289
|
+
"#{right_col_name} = #{right_col_name} + #{child_occupies}",
|
290
|
+
"#{depth_column} >= #{self[depth_column]} " +
|
291
|
+
"AND #{left_col_name} > #{self[right_col_name]}"
|
292
|
+
)
|
293
|
+
end
|
294
|
+
[self, child].map{|e| e.reload }
|
295
|
+
true
|
296
|
+
end
|
297
|
+
|
298
|
+
# Determine lft and rgt for a child item, taking into account the number of child and grandchild nodes it has.
|
299
|
+
# Normally you would not use this directly
|
300
|
+
def determine_range_for_child(child)
|
301
|
+
new_left = begin
|
302
|
+
right_bound_child = self.class.find(:first,
|
303
|
+
:conditions => "#{scope_condition} AND #{parent_column} = #{self.id} AND id != #{child.id}", :order => "#{right_col_name} DESC")
|
304
|
+
right_bound_child ? (right_bound_child[right_col_name] + 1) : (self[left_col_name] + 1)
|
305
|
+
end
|
306
|
+
new_right = new_left + (child[right_col_name] - child[left_col_name])
|
307
|
+
[new_left, new_right]
|
308
|
+
end
|
309
|
+
|
310
|
+
# Returns the number of children and grandchildren of this object
|
311
|
+
def child_count
|
312
|
+
return 0 unless (!new_record? && might_have_children?) # shortcut
|
313
|
+
|
314
|
+
@child_count ||= self.class.scoped(scope_hash_for_branch).count
|
315
|
+
end
|
316
|
+
alias_method :children_count, :child_count
|
317
|
+
|
318
|
+
# Shortcut to determine if our left and right values allow for possible children.
|
319
|
+
# Note the difference in wording between might_have and has - if this method returns false,
|
320
|
+
# it means you should look no further. If it returns true, you should really examine
|
321
|
+
# the children to be sure
|
322
|
+
def might_have_children?
|
323
|
+
(self[right_col_name] - self[left_col_name]) > 1
|
324
|
+
end
|
325
|
+
|
326
|
+
# Returns a set of itself and all of its nested children. Any additional
|
327
|
+
# options scope the find call.
|
328
|
+
def full_set(extras = {})
|
329
|
+
[self] + all_children(extras)
|
330
|
+
end
|
331
|
+
alias_method :all_children_and_self, :full_set
|
332
|
+
|
333
|
+
# Returns a set of all of its children and nested children. Any additional
|
334
|
+
# options scope the find call.
|
335
|
+
def all_children(extras = {})
|
336
|
+
return [] unless might_have_children? # optimization shortcut
|
337
|
+
self.class.scoped(scope_hash_for_branch).find(:all, extras)
|
338
|
+
end
|
339
|
+
|
340
|
+
# Returns scoping options suitable for fetching all children
|
341
|
+
def scope_hash_for_branch
|
342
|
+
{:conditions => conditions_for_all_children, :order => "#{left_col_name} ASC" }
|
343
|
+
end
|
344
|
+
|
345
|
+
# Returns scopint options suitable for fetching direct children
|
346
|
+
def scope_hash_for_direct_children
|
347
|
+
{:conditions => "#{scope_condition} AND #{parent_column} = #{self.id}", :order => "#{left_col_name} ASC"}
|
348
|
+
end
|
349
|
+
|
350
|
+
# Get conditions for direct and indirect children of this record
|
351
|
+
def conditions_for_all_children
|
352
|
+
pk = "#{self.class.table_name} WHERE id = #{self.id}"
|
353
|
+
inner_r = "(SELECT #{root_column} FROM #{pk})"
|
354
|
+
inner_d = "(SELECT #{depth_column} FROM #{pk})"
|
355
|
+
inner_l = "(SELECT #{left_col_name} FROM #{pk})"
|
356
|
+
inner_r = "(SELECT #{right_col_name} FROM #{pk})"
|
357
|
+
inner_rt = "(SELECT #{root_column} FROM #{pk})"
|
358
|
+
|
359
|
+
"#{scope_condition} AND #{inner_rt} AND " +
|
360
|
+
"#{depth_column} > #{inner_d} AND " +
|
361
|
+
"#{left_col_name} > #{inner_l} AND #{right_col_name} < #{inner_r}"
|
362
|
+
end
|
363
|
+
|
364
|
+
# Get conditions to find myself and my siblings
|
365
|
+
def conditions_for_self_and_siblings
|
366
|
+
inner_select = "SELECT %s FROM %s WHERE id = %d" % [parent_column, self.class.table_name, id]
|
367
|
+
"#{scope_condition} AND #{parent_column} = (#{inner_select})"
|
368
|
+
end
|
369
|
+
|
370
|
+
# Get immediate siblings, ordered
|
371
|
+
def siblings(extras = {})
|
372
|
+
scope = {
|
373
|
+
:conditions => "#{conditions_for_self_and_siblings} AND id != #{self.id}",
|
374
|
+
:order => "#{left_col_name} ASC"
|
375
|
+
}
|
376
|
+
self.class.scoped(scope).find(:all, extras)
|
377
|
+
end
|
378
|
+
|
379
|
+
# Get myself and siblings, ordered
|
380
|
+
def siblings_and_self(extras = {})
|
381
|
+
scope = {
|
382
|
+
:conditions => "#{conditions_for_self_and_siblings}",
|
383
|
+
:order => "#{left_col_name} ASC"
|
384
|
+
}
|
385
|
+
self.class.scoped(scope).find(:all, extras)
|
386
|
+
end
|
387
|
+
|
388
|
+
# Returns a set of only this entry's immediate children, also ordered by position. Any additional
|
389
|
+
# options scope the find call.
|
390
|
+
def direct_children(extras = {})
|
391
|
+
return [] unless might_have_children? # optimize!
|
392
|
+
self.class.scoped(scope_hash_for_direct_children).find(:all, extras)
|
393
|
+
end
|
394
|
+
|
395
|
+
# Make this item a root node (moves it to the end of the root node list in the same scope)
|
396
|
+
def promote_to_root
|
397
|
+
return false if new_record?
|
398
|
+
|
399
|
+
transaction do
|
400
|
+
my_width = child_count * 2
|
401
|
+
|
402
|
+
# Use the copy in the DB to infer keys
|
403
|
+
stale = self.class.find(self.id, :select => [left_col_name, right_col_name, root_column, depth_column].join(', '))
|
404
|
+
|
405
|
+
old_left, old_right, old_root, old_depth = stale[left_col_name], stale[right_col_name], stale[root_column], stale[depth_column]
|
406
|
+
|
407
|
+
|
408
|
+
self[parent_column] = 0 # Signal the root node
|
409
|
+
new_left, new_right = get_left_and_right_for(self, my_width)
|
410
|
+
|
411
|
+
move_by = new_left - old_left
|
412
|
+
move_depth_by = old_depth
|
413
|
+
|
414
|
+
# bring the child and its grandchildren over
|
415
|
+
self.class.update_all(
|
416
|
+
"#{depth_column} = #{depth_column} - #{move_depth_by}," +
|
417
|
+
"#{root_column} = #{self.id}," +
|
418
|
+
"#{left_col_name} = #{left_col_name} + #{move_by}," +
|
419
|
+
"#{right_col_name} = #{right_col_name} + #{move_by}",
|
420
|
+
"#{scope_condition} AND #{left_col_name} >= #{old_left} AND #{right_col_name} <= #{old_right}" +
|
421
|
+
" AND #{root_column} = #{old_root}"
|
422
|
+
)
|
423
|
+
|
424
|
+
# update self, assume valid object for speed
|
425
|
+
self.class.update_all(
|
426
|
+
"#{root_column} = #{self.id}, #{depth_column} = 0, #{parent_column} = 0, #{left_col_name} = #{new_left}, #{right_col_name} = #{new_right}",
|
427
|
+
"id = #{self.id}"
|
428
|
+
)
|
429
|
+
|
430
|
+
# Blow away the memoized counts
|
431
|
+
self.reload
|
432
|
+
end
|
433
|
+
true
|
434
|
+
end
|
435
|
+
|
436
|
+
|
437
|
+
private
|
438
|
+
|
439
|
+
def register_parent_id_before_update
|
440
|
+
@old_parent_id = self.class.connection.select_value("SELECT #{parent_column} FROM #{self.class.table_name} WHERE id = #{self.id}")
|
441
|
+
true
|
442
|
+
end
|
443
|
+
|
444
|
+
def replant_after_update
|
445
|
+
if @old_parent_id.nil? || (@old_parent_id == self[parent_column])
|
446
|
+
return true
|
447
|
+
# If the new parent_id is nil, it means we are promoted to woot node
|
448
|
+
elsif self[parent_column].nil? || self[parent_column].zero?
|
449
|
+
promote_to_root
|
450
|
+
else
|
451
|
+
self.class.find(self[parent_column]).add_child(self)
|
452
|
+
end
|
453
|
+
|
454
|
+
true
|
455
|
+
end
|
456
|
+
|
457
|
+
def get_left_and_right_for(item, width)
|
458
|
+
last_root_node = item.class.find(:first, :conditions => "#{item.scope_condition} AND #{item.parent_column} = 0 AND id != #{item.id}",
|
459
|
+
:order => "#{right_col_name} DESC", :limit => 1, :select => [right_col_name]) # spare!
|
460
|
+
offset = last_root_node ? last_root_node[right_col_name] : 0
|
461
|
+
|
462
|
+
[(offset+1), (offset + width + 2)]
|
463
|
+
end
|
464
|
+
|
465
|
+
|
466
|
+
end #InstanceMethods
|
467
|
+
end
|
468
|
+
end
|
@@ -0,0 +1,474 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'active_record'
|
3
|
+
require 'active_support'
|
4
|
+
require 'test/spec'
|
5
|
+
|
6
|
+
require File.dirname(__FILE__) + '/../init'
|
7
|
+
|
8
|
+
ActiveRecord::Base.establish_connection(:adapter => 'sqlite3', :dbfile => ':memory:')
|
9
|
+
ActiveRecord::Migration.verbose = false
|
10
|
+
ActiveRecord::Schema.define do
|
11
|
+
create_table :nodes, :force => true do |t|
|
12
|
+
|
13
|
+
t.string :name, :null => false
|
14
|
+
t.integer :project_id
|
15
|
+
|
16
|
+
# Bookkeeping for threads
|
17
|
+
t.integer :root_id, :default => 0, :null => false
|
18
|
+
t.integer :parent_id, :default => 0, :null => false
|
19
|
+
t.integer :depth, :maxlength => 5, :default => 0, :null => false
|
20
|
+
t.integer :lft, :default => 0, :null => false
|
21
|
+
t.integer :rgt, :default => 0, :null => false
|
22
|
+
end
|
23
|
+
|
24
|
+
# Anonimous tables for anonimous classes
|
25
|
+
(1..20).each do | i |
|
26
|
+
create_table "an#{i}s", :force => true do # anis!
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
class NodeTest < Test::Unit::TestCase
|
32
|
+
|
33
|
+
class Node < ActiveRecord::Base
|
34
|
+
set_table_name "nodes"
|
35
|
+
make_like_a_tree :scope => :project
|
36
|
+
def _lr
|
37
|
+
[lft, rgt]
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def emit(attributes = {})
|
42
|
+
Node.create!({:project_id => 1}.merge(attributes))
|
43
|
+
end
|
44
|
+
|
45
|
+
def emit_many(how_many, extras = {})
|
46
|
+
(1..how_many).map{|i| emit({:name => "Item_#{i}"}.merge(extras)) }
|
47
|
+
end
|
48
|
+
|
49
|
+
def reload(*all)
|
50
|
+
all.flatten.map(&:reload)
|
51
|
+
end
|
52
|
+
|
53
|
+
def setup
|
54
|
+
Node.delete_all
|
55
|
+
super
|
56
|
+
end
|
57
|
+
|
58
|
+
# Silence!
|
59
|
+
def default_test; end
|
60
|
+
end
|
61
|
+
|
62
|
+
context "A Node with attributes that change in flight should", NodeTest do
|
63
|
+
specify "return same siblings no matter what parent_id the record has assigned" do
|
64
|
+
node1, node2, node3 = emit_many(3)
|
65
|
+
reload(node1, node2, node3)
|
66
|
+
|
67
|
+
node1.parent_id = 100
|
68
|
+
node2.parent_id = 300
|
69
|
+
node3.parent_id = 600
|
70
|
+
|
71
|
+
node1.siblings.should.equal [node2, node3]
|
72
|
+
end
|
73
|
+
|
74
|
+
specify "should be promoted to root no matter what changes to the attributes are made" do
|
75
|
+
node1, node2, node3 = emit_many(3)
|
76
|
+
node4 = emit :name => "A child", :parent_id => node2.id
|
77
|
+
|
78
|
+
reload(node4)
|
79
|
+
|
80
|
+
node4.lft, node4.rgt, node4.depth = 300, 500, 164
|
81
|
+
lambda { node4.promote_to_root}.should.not.raise
|
82
|
+
|
83
|
+
reload(node4)
|
84
|
+
|
85
|
+
node4.depth.should.equal 0
|
86
|
+
node4.parent_id.should.equal 0
|
87
|
+
node4._lr.should.equal [9,10]
|
88
|
+
end
|
89
|
+
|
90
|
+
specify "return same all_children no matter what left and right the record has assigned" do
|
91
|
+
node1, node2, node3 = emit_many(3)
|
92
|
+
children = emit_many(10, :parent_id => node1.id)
|
93
|
+
|
94
|
+
reload(node1)
|
95
|
+
node1.all_children.should.equal children
|
96
|
+
|
97
|
+
node1.lft, node1.rgt, node1.depth, node1.root_id = 300, 400, 23, 67
|
98
|
+
|
99
|
+
node1.all_children.should.equal children
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
context "A new Node should", NodeTest do
|
104
|
+
specify "not allow promote_to_root" do
|
105
|
+
Node.new.promote_to_root.should.equal false
|
106
|
+
end
|
107
|
+
|
108
|
+
specify "not allow move_to" do
|
109
|
+
Node.new.move_to(10).should.equal false
|
110
|
+
end
|
111
|
+
|
112
|
+
specify "not allow add_child" do
|
113
|
+
Node.new.add_child(Node.new).should.equal false
|
114
|
+
end
|
115
|
+
|
116
|
+
specify "not be accepted for add_child" do
|
117
|
+
emit(:name => "Foo").add_child(Node.new).should.equal false
|
118
|
+
Node.new.add_child(Node.new).should.equal false
|
119
|
+
end
|
120
|
+
|
121
|
+
specify "identify itself as root if parent is zero or nil" do
|
122
|
+
Node.new.should.be.root
|
123
|
+
Node.new.should.not.be.child
|
124
|
+
end
|
125
|
+
|
126
|
+
specify "identify itself as a child if parent is not zero" do
|
127
|
+
Node.new(:parent_id => 100).should.be.child
|
128
|
+
Node.new(:parent_id => 100).should.not.be.root
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
context "A Node used with OrderedTree should", NodeTest do
|
133
|
+
Node = NodeTest::Node
|
134
|
+
|
135
|
+
specify "support full_set" do
|
136
|
+
folder1, folder2 = emit(:name => "One"), emit(:name => "Two")
|
137
|
+
three = emit(:name => "subfolder", :parent_id => folder1.id)
|
138
|
+
|
139
|
+
folder1.all_children_and_self.should.equal folder1.full_set
|
140
|
+
end
|
141
|
+
|
142
|
+
specify "return a proper scope condition" do
|
143
|
+
Node.new(:project_id => 1).scope_condition.should.equal "project_id = 1"
|
144
|
+
Node.new(:project_id => nil).scope_condition.should.equal "project_id IS NULL"
|
145
|
+
end
|
146
|
+
|
147
|
+
specify "return a bypass scope condition with no scope" do
|
148
|
+
class An2 < ActiveRecord::Base
|
149
|
+
make_like_a_tree
|
150
|
+
end
|
151
|
+
An2.new.scope_condition.should.equal "(1=1)"
|
152
|
+
end
|
153
|
+
|
154
|
+
specify "return a proper left and right column if they have been customized" do
|
155
|
+
class An1 < ActiveRecord::Base
|
156
|
+
make_like_a_tree :left_column => :foo, :right_column => :bar
|
157
|
+
end
|
158
|
+
An1.new.left_col_name.should.equal "foo"
|
159
|
+
An1.new.right_col_name.should.equal "bar"
|
160
|
+
end
|
161
|
+
|
162
|
+
specify "return a proper depth column if it has been customized" do
|
163
|
+
class An3 < ActiveRecord::Base
|
164
|
+
make_like_a_tree :depth_column => :niveau
|
165
|
+
end
|
166
|
+
An3.new.depth_column.should.equal "niveau"
|
167
|
+
end
|
168
|
+
|
169
|
+
specify "create root nodes with ordered left and right" do
|
170
|
+
groups = (0...2).map do | idx |
|
171
|
+
emit :name => "Group_#{idx}"
|
172
|
+
end
|
173
|
+
reload(groups)
|
174
|
+
|
175
|
+
groups[0]._lr.should.equal [1, 2]
|
176
|
+
groups[1]._lr.should.equal [3,4]
|
177
|
+
end
|
178
|
+
|
179
|
+
specify "create a good child node" do
|
180
|
+
|
181
|
+
root_node = emit :name => "Mother"
|
182
|
+
child_node = emit :name => "Daughter", :parent_id => root_node.id
|
183
|
+
|
184
|
+
reload(root_node, child_node)
|
185
|
+
|
186
|
+
root_node.child_can_be_added?(child_node).should.blaming("possible move").equal true
|
187
|
+
root_node._lr.should.blaming("root node with one subset is 1,4").equal [1, 4]
|
188
|
+
child_node._lr.should.blaming("first in nested range is 2,3").equal [2, 3]
|
189
|
+
end
|
190
|
+
|
191
|
+
specify "create a number of good child nodes" do
|
192
|
+
|
193
|
+
root_node = emit :name => "Mother"
|
194
|
+
child_nodes = ["Daughter", "Brother"].map { |n| emit :name => n, :parent_id => root_node.id }
|
195
|
+
|
196
|
+
reload(root_node, child_nodes)
|
197
|
+
|
198
|
+
root_node._lr.should.blaming("extended range").equal [1, 6]
|
199
|
+
child_nodes[0]._lr.should.blaming("first in sequence is 2,3").equal [2, 3]
|
200
|
+
child_nodes[1]._lr.should.blaming("second in sequence is 4,5").equal [4, 5]
|
201
|
+
|
202
|
+
child_nodes.each do | cn |
|
203
|
+
cn.depth.should.blaming("depth increase").equal 1
|
204
|
+
cn.root_id.should.blaming("proper root assignment").equal root_node.id
|
205
|
+
cn.parent_id.should.blaming("parent assignment").equal root_node.id
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
specify "shift siblings to the right on child assignment to their left neighbour" do
|
210
|
+
root_node = emit :name => "Root one"
|
211
|
+
|
212
|
+
sub_node = emit :name => "Child 1", :parent_id => root_node.id
|
213
|
+
sub_node_sibling = emit :name => "Child 2", :parent_id => root_node.id
|
214
|
+
|
215
|
+
reload(sub_node_sibling)
|
216
|
+
sub_node_sibling._lr.should.equal [4,5]
|
217
|
+
|
218
|
+
# Now inject a child into sub_node
|
219
|
+
grandchild = emit :name => "Grandchild via Child 1", :parent_id => sub_node.id
|
220
|
+
|
221
|
+
reload(sub_node_sibling)
|
222
|
+
sub_node_sibling._lr.should.blaming("shifted right because a child was injected to the left of us").equal [6,7]
|
223
|
+
|
224
|
+
reload(root_node)
|
225
|
+
root_node._lr.should.blaming("increased range for the grandchild").equal [1,8]
|
226
|
+
end
|
227
|
+
|
228
|
+
specify "make nodes their own roots" do
|
229
|
+
a, b = %w(a b).map{|n| emit :name => n }
|
230
|
+
a.root_id.should.equal a.id
|
231
|
+
b.root_id.should.equal b.id
|
232
|
+
end
|
233
|
+
|
234
|
+
specify "replant a branch" do
|
235
|
+
root_node_1 = emit :name => "First root"
|
236
|
+
root_node_2 = emit :name => "Second root"
|
237
|
+
root_node_3 = emit :name => "Third root"
|
238
|
+
|
239
|
+
# Now make a subtree on the third root node
|
240
|
+
child = emit :name => "Child", :parent_id => root_node_3.id
|
241
|
+
grand_child = emit :name => "Grand child", :parent_id => child.id
|
242
|
+
grand_grand_child = emit :name => "Grand grand child", :parent_id => grand_child.id
|
243
|
+
|
244
|
+
reload(root_node_1, root_node_2, root_node_3, child, grand_child, grand_grand_child)
|
245
|
+
|
246
|
+
child._lr.should.blaming("the complete branch indices").equal [6,11]
|
247
|
+
root_node_3._lr.should.blaming("inclusive for the child branch").equal [5, 12]
|
248
|
+
|
249
|
+
root_node_1.add_child(child)
|
250
|
+
|
251
|
+
reload(root_node_1, root_node_2)
|
252
|
+
|
253
|
+
root_node_1._lr.should.blaming("branch containment expanded the range").equal [1, 8]
|
254
|
+
root_node_2._lr.should.blaming("shifted right to make room").equal [9, 10]
|
255
|
+
end
|
256
|
+
|
257
|
+
specify "report size after moving a branch from underneath" do
|
258
|
+
root_node_1 = emit :name => "First root"
|
259
|
+
root_node_2 = emit :name => "First root"
|
260
|
+
|
261
|
+
child = emit :name => "Some child", :parent_id => root_node_2.id
|
262
|
+
|
263
|
+
root_node_2.reload
|
264
|
+
|
265
|
+
root_node_2.might_have_children?.should.blaming("might_have_children? is true - our indices are #{root_node_2._lr.inspect}").equal true
|
266
|
+
root_node_2.child_count.should.blaming("only one child available").equal 1
|
267
|
+
|
268
|
+
# Now replant the child
|
269
|
+
root_node_1.add_child(child)
|
270
|
+
reload(root_node_1, root_node_2)
|
271
|
+
|
272
|
+
root_node_2.child_count.should.blaming("all children removed").be.zero
|
273
|
+
root_node_1.child_count.should.blaming("now has one child").equal 1
|
274
|
+
end
|
275
|
+
|
276
|
+
specify "return siblings" do
|
277
|
+
root_1 = emit :name => "Foo"
|
278
|
+
root_2 = emit :name => "Bar"
|
279
|
+
|
280
|
+
reload(root_1, root_2)
|
281
|
+
|
282
|
+
root_1.siblings.should.equal [root_2]
|
283
|
+
root_2.siblings.should.equal [root_1]
|
284
|
+
end
|
285
|
+
|
286
|
+
specify "return siblings and self" do
|
287
|
+
root_1 = emit :name => "Foo"
|
288
|
+
root_2 = emit :name => "Bar"
|
289
|
+
|
290
|
+
reload(root_1, root_2)
|
291
|
+
|
292
|
+
root_1.siblings_and_self.should.equal [root_1, root_2]
|
293
|
+
root_2.siblings_and_self.should.equal [root_1, root_2]
|
294
|
+
end
|
295
|
+
|
296
|
+
specify "provide index_in_parent" do
|
297
|
+
root_nodes = (0...3).map do | i |
|
298
|
+
emit :name => "Root_#{i}"
|
299
|
+
end
|
300
|
+
|
301
|
+
root_nodes.each_with_index do | rn, i |
|
302
|
+
rn.should.respond_to :index_in_parent
|
303
|
+
rn.index_in_parent.should.blaming("is at index #{i}").equal i
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
specify 'do nothing on move when only item in the list' do
|
308
|
+
a = emit :name => "Boo"
|
309
|
+
a.move_to(0).should.equal true
|
310
|
+
a.move_to(200).should.equal true
|
311
|
+
end
|
312
|
+
|
313
|
+
specify "do nothing if we move from the same position to the same position" do
|
314
|
+
a = emit :name => "Foo"
|
315
|
+
b = emit :name => "Boo"
|
316
|
+
|
317
|
+
a.move_to(0).should.equal true
|
318
|
+
b.move_to(1).should.equal true
|
319
|
+
end
|
320
|
+
|
321
|
+
specify "move a root node up" do
|
322
|
+
root_1 = emit :name => "First root"
|
323
|
+
root_2 = emit :name => "Second root"
|
324
|
+
root_2.move_to(0)
|
325
|
+
|
326
|
+
reload(root_1, root_2)
|
327
|
+
|
328
|
+
root_1._lr.should.equal [3, 4]
|
329
|
+
root_2._lr.should.equal [1, 2]
|
330
|
+
end
|
331
|
+
|
332
|
+
specify "reorder including subtrees" do
|
333
|
+
root_1 = emit :name => "First root"
|
334
|
+
root_2 = emit :name => "Second root with children"
|
335
|
+
4.times{ emit :name => "Child of root2", :parent_id => root_2.id }
|
336
|
+
|
337
|
+
reload(root_2)
|
338
|
+
root_2._lr.should.equal [3, 12]
|
339
|
+
|
340
|
+
root_2.move_to(0)
|
341
|
+
reload(root_2)
|
342
|
+
|
343
|
+
root_2._lr.should.blaming("Shifted range").equal [1, 10]
|
344
|
+
root_2.children_count.should.blaming("the same children count").equal 4
|
345
|
+
|
346
|
+
reload(root_1)
|
347
|
+
root_1._lr.should.blaming("Shifted down").equal [11, 12]
|
348
|
+
root_1.children_count.should.blaming("the same children count").be.zero
|
349
|
+
end
|
350
|
+
|
351
|
+
specify "support move_up" do
|
352
|
+
root_1, root_2 = emit(:name => "First"), emit(:name => "Second")
|
353
|
+
root_2.should.respond_to :move_up
|
354
|
+
|
355
|
+
root_2.move_up
|
356
|
+
|
357
|
+
reload(root_1, root_2)
|
358
|
+
root_2._lr.should.equal [1,2]
|
359
|
+
end
|
360
|
+
|
361
|
+
specify "support move_down" do
|
362
|
+
root_1, root_2 = emit(:name => "First"), emit(:name => "Second")
|
363
|
+
|
364
|
+
root_1.should.respond_to :move_down
|
365
|
+
root_1.move_up
|
366
|
+
|
367
|
+
reload(root_1, root_2)
|
368
|
+
root_2._lr.should.equal [1,2]
|
369
|
+
root_1._lr.should.equal [3,4]
|
370
|
+
end
|
371
|
+
|
372
|
+
specify "support move_to_top" do
|
373
|
+
root_1, root_2, root_3 = emit(:name => "First"), emit(:name => "Second"), emit(:name => "Third")
|
374
|
+
|
375
|
+
root_3.should.respond_to :move_to_top
|
376
|
+
root_3.move_to_top
|
377
|
+
reload(root_1, root_2, root_3)
|
378
|
+
|
379
|
+
root_3._lr.should.blaming("is now on top").equal [1,2]
|
380
|
+
root_1._lr.should.blaming("is now second").equal [3,4]
|
381
|
+
root_2._lr.should.blaming("is now third").equal [5,6]
|
382
|
+
end
|
383
|
+
|
384
|
+
specify "support move_to_bottom" do
|
385
|
+
root_1, root_2, root_3, root_4 = (1..4).map{|e| emit :name => "Root_#{e}"}
|
386
|
+
root_1.should.respond_to :move_to_bottom
|
387
|
+
|
388
|
+
root_1.move_to_bottom
|
389
|
+
reload(root_1, root_2, root_3, root_4)
|
390
|
+
|
391
|
+
root_2._lr.should.blaming("is now on top").equal [1,2]
|
392
|
+
root_1._lr.should.blaming("is now on the bottom").equal [7,8]
|
393
|
+
end
|
394
|
+
|
395
|
+
specify "support move_to_top for the second item of three" do
|
396
|
+
a, b, c = emit_many(3)
|
397
|
+
b.move_to_top
|
398
|
+
reload(a, b, c)
|
399
|
+
|
400
|
+
a._lr.should.equal [3, 4]
|
401
|
+
b._lr.should.equal [1, 2]
|
402
|
+
c._lr.should.equal [5, 6]
|
403
|
+
end
|
404
|
+
|
405
|
+
specify "should not allow reparenting an item into its child" do
|
406
|
+
root = emit :name => "foo"
|
407
|
+
child = emit :name => "bar", :parent_id => root.id
|
408
|
+
reload(root, child)
|
409
|
+
|
410
|
+
child.child_can_be_added?(root).should.blaming("Impossible move").equal false
|
411
|
+
lambda { child.add_child!(root)}.should.raise(Julik::MakeLikeTree::ImpossibleReparent)
|
412
|
+
child.add_child(root).should.equal false
|
413
|
+
end
|
414
|
+
|
415
|
+
specify "support additional find options via scoped finds on all_children" do
|
416
|
+
root = emit :name => "foo"
|
417
|
+
child = emit :name => "bar", :parent_id => root.id
|
418
|
+
another_child = emit :name => "another", :parent_id => root.id
|
419
|
+
|
420
|
+
reload(root)
|
421
|
+
|
422
|
+
root.all_children.should.equal [child, another_child]
|
423
|
+
root.all_children(:conditions => {:name => "another"}).should.equal [another_child]
|
424
|
+
end
|
425
|
+
|
426
|
+
specify "support additional find options via scoped finds on direct_children" do
|
427
|
+
root = emit :name => "foo"
|
428
|
+
anoter_root = emit :name => "another"
|
429
|
+
|
430
|
+
child = emit :name => "bar", :parent_id => root.id
|
431
|
+
another_child = emit :name => "another", :parent_id => root.id
|
432
|
+
|
433
|
+
reload(root)
|
434
|
+
|
435
|
+
root.direct_children.should.equal [child, another_child]
|
436
|
+
root.direct_children(:conditions => {:name => "another"}).should.equal [another_child]
|
437
|
+
end
|
438
|
+
|
439
|
+
specify "support additional find options via scoped finds on full_set" do
|
440
|
+
root = emit :name => "foo"
|
441
|
+
anoter_root = emit :name => "another"
|
442
|
+
child_1 = emit :name => "another", :parent_id => root.id
|
443
|
+
child_2 = emit :name => "outsider", :parent_id => root.id
|
444
|
+
|
445
|
+
reload(root)
|
446
|
+
|
447
|
+
root.full_set(:conditions => {:name => "another"}).should.equal [root, child_1]
|
448
|
+
end
|
449
|
+
|
450
|
+
specify "support promote_to_root" do
|
451
|
+
a, b = emit_many(2)
|
452
|
+
c = emit(:name => "Subtree", :parent_id => a.id)
|
453
|
+
|
454
|
+
reload(a, b, c)
|
455
|
+
c.promote_to_root
|
456
|
+
|
457
|
+
reload(a, b, c)
|
458
|
+
|
459
|
+
c.depth.should.blaming("is at top level").equal 0
|
460
|
+
c.root_id.should.blaming("is now self-root").equal c.id
|
461
|
+
c._lr.should.blaming("now promoted to root").equal [7, 8]
|
462
|
+
end
|
463
|
+
|
464
|
+
specify "support replanting by changing parent_id" do
|
465
|
+
a, b = emit_many(2)
|
466
|
+
sub = emit :name => "Child", :parent_id => a.id
|
467
|
+
sub.update_attributes(:parent_id => b.id)
|
468
|
+
|
469
|
+
reload(a, b, sub)
|
470
|
+
a.all_children.should.blaming("replanted branch from there").not.include( sub)
|
471
|
+
b.all_children.should.blaming("replanted branch here").include( sub)
|
472
|
+
end
|
473
|
+
|
474
|
+
end
|
metadata
ADDED
@@ -0,0 +1,110 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: make_like_a_tree
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 17
|
5
|
+
prerelease:
|
6
|
+
segments:
|
7
|
+
- 1
|
8
|
+
- 0
|
9
|
+
- 3
|
10
|
+
version: 1.0.3
|
11
|
+
platform: ruby
|
12
|
+
authors:
|
13
|
+
- Julik Tarkhanov
|
14
|
+
autorequire:
|
15
|
+
bindir: bin
|
16
|
+
cert_chain: []
|
17
|
+
|
18
|
+
date: 2011-06-22 00:00:00 +02:00
|
19
|
+
default_executable:
|
20
|
+
dependencies:
|
21
|
+
- !ruby/object:Gem::Dependency
|
22
|
+
name: hoe
|
23
|
+
prerelease: false
|
24
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ">="
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
hash: 35
|
30
|
+
segments:
|
31
|
+
- 2
|
32
|
+
- 9
|
33
|
+
- 4
|
34
|
+
version: 2.9.4
|
35
|
+
type: :development
|
36
|
+
version_requirements: *id001
|
37
|
+
description: |-
|
38
|
+
Implement orderable trees in ActiveRecord using the nested set model, with multiple roots and scoping, and most importantly user-defined
|
39
|
+
ordering of subtrees. Fetches preordered trees in one go, updates are write-heavy.
|
40
|
+
|
41
|
+
This is a substantially butchered-up version/offspring of acts_as_threaded. The main additional perk is the ability
|
42
|
+
to reorder nodes, which are always fetched ordered. Example:
|
43
|
+
|
44
|
+
root = Folder.create! :name => "Main folder"
|
45
|
+
subfolder_1 = Folder.create! :name => "Subfolder", :parent_id => root.id
|
46
|
+
subfolder_2 = Folder.create! :name => "Another subfolder", :parent_id => root.id
|
47
|
+
|
48
|
+
subfolder_2.move_to_top # just like acts_as_list but nestedly awesome
|
49
|
+
root.all_children # => [subfolder_2, subfolder_1]
|
50
|
+
|
51
|
+
See the rdocs for examples the method names. It also inherits the awesome properties of acts_as_threaded, namely
|
52
|
+
materialized depth, root_id and parent_id values on each object which are updated when nodes get moved.
|
53
|
+
|
54
|
+
Thanks to the authors of acts_as_threaded, awesome_nested_set, better_nested_set and all the others for inspiration.
|
55
|
+
email:
|
56
|
+
- me@julik.nl
|
57
|
+
executables: []
|
58
|
+
|
59
|
+
extensions: []
|
60
|
+
|
61
|
+
extra_rdoc_files:
|
62
|
+
- History.txt
|
63
|
+
- Manifest.txt
|
64
|
+
- README.txt
|
65
|
+
files:
|
66
|
+
- History.txt
|
67
|
+
- Manifest.txt
|
68
|
+
- README.txt
|
69
|
+
- Rakefile
|
70
|
+
- init.rb
|
71
|
+
- lib/make_like_a_tree.rb
|
72
|
+
- test/test_ordered_tree.rb
|
73
|
+
- .gemtest
|
74
|
+
has_rdoc: true
|
75
|
+
homepage: http://github.com/julik/make_like_a_tree
|
76
|
+
licenses: []
|
77
|
+
|
78
|
+
post_install_message:
|
79
|
+
rdoc_options:
|
80
|
+
- --main
|
81
|
+
- README.txt
|
82
|
+
require_paths:
|
83
|
+
- lib
|
84
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
85
|
+
none: false
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
hash: 3
|
90
|
+
segments:
|
91
|
+
- 0
|
92
|
+
version: "0"
|
93
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
94
|
+
none: false
|
95
|
+
requirements:
|
96
|
+
- - ">="
|
97
|
+
- !ruby/object:Gem::Version
|
98
|
+
hash: 3
|
99
|
+
segments:
|
100
|
+
- 0
|
101
|
+
version: "0"
|
102
|
+
requirements: []
|
103
|
+
|
104
|
+
rubyforge_project: make_like_a_tree
|
105
|
+
rubygems_version: 1.6.2
|
106
|
+
signing_key:
|
107
|
+
specification_version: 3
|
108
|
+
summary: Implement orderable trees in ActiveRecord using the nested set model, with multiple roots and scoping, and most importantly user-defined ordering of subtrees
|
109
|
+
test_files:
|
110
|
+
- test/test_ordered_tree.rb
|