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.
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