make_like_a_tree 1.0.3

Sign up to get free protection for your applications and to get access to all the features.
File without changes
@@ -0,0 +1,6 @@
1
+ === 1.0.0 / 2009-01-25
2
+
3
+ * 1 major enhancement
4
+
5
+ * duh!
6
+
@@ -0,0 +1,7 @@
1
+ History.txt
2
+ Manifest.txt
3
+ README.txt
4
+ Rakefile
5
+ init.rb
6
+ lib/make_like_a_tree.rb
7
+ test/test_ordered_tree.rb
@@ -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.
@@ -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,2 @@
1
+ require File.dirname(__FILE__) + '/lib/make_like_a_tree'
2
+ Julik::MakeLikeTree.bootstrap!
@@ -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