mongoid-tree-rational 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,443 @@
1
+ module Mongoid
2
+ ##
3
+ # = Mongoid::Tree
4
+ #
5
+ # This module extends any Mongoid document with tree functionality.
6
+ #
7
+ # == Usage
8
+ #
9
+ # Simply include the module in any Mongoid document:
10
+ #
11
+ # class Node
12
+ # include Mongoid::Document
13
+ # include Mongoid::Tree
14
+ # end
15
+ #
16
+ # === Using the tree structure
17
+ #
18
+ # Each document references many children. You can access them using the <tt>#children</tt> method.
19
+ #
20
+ # node = Node.create
21
+ # node.children.create
22
+ # node.children.count # => 1
23
+ #
24
+ # Every document references one parent (unless it's a root document).
25
+ #
26
+ # node = Node.create
27
+ # node.parent # => nil
28
+ # node.children.create
29
+ # node.children.first.parent # => node
30
+ #
31
+ # === Destroying
32
+ #
33
+ # Mongoid::Tree does not handle destroying of nodes by default. However it provides
34
+ # several strategies that help you to deal with children of deleted documents. You can
35
+ # simply add them as <tt>before_destroy</tt> callbacks.
36
+ #
37
+ # Available strategies are:
38
+ #
39
+ # * :nullify_children -- Sets the children's parent_id to null
40
+ # * :move_children_to_parent -- Moves the children to the current document's parent
41
+ # * :destroy_children -- Destroys all children by calling their #destroy method (invokes callbacks)
42
+ # * :delete_descendants -- Deletes all descendants using a database query (doesn't invoke callbacks)
43
+ #
44
+ # Example:
45
+ #
46
+ # class Node
47
+ # include Mongoid::Document
48
+ # include Mongoid::Tree
49
+ #
50
+ # before_destroy :nullify_children
51
+ # end
52
+ #
53
+ # === Callbacks
54
+ #
55
+ # Mongoid::Tree offers callbacks for its rearranging process. This enables you to
56
+ # rebuild certain fields when the document was moved in the tree. Rearranging happens
57
+ # before the document is validated. This gives you a chance to validate your additional
58
+ # changes done in your callbacks. See ActiveModel::Callbacks and ActiveSupport::Callbacks
59
+ # for further details on callbacks.
60
+ #
61
+ # Example:
62
+ #
63
+ # class Page
64
+ # include Mongoid::Document
65
+ # include Mongoid::Tree
66
+ #
67
+ # after_rearrange :rebuild_path
68
+ #
69
+ # field :slug
70
+ # field :path
71
+ #
72
+ # private
73
+ #
74
+ # def rebuild_path
75
+ # self.path = self.ancestors_and_self.collect(&:slug).join('/')
76
+ # end
77
+ # end
78
+ #
79
+ module Tree
80
+ extend ActiveSupport::Concern
81
+
82
+ autoload :Ordering, 'mongoid/tree/ordering'
83
+ autoload :Traversal, 'mongoid/tree/traversal'
84
+ autoload :RationalNumbering, 'mongoid/tree/rational_numbering'
85
+
86
+ included do
87
+ has_many :children, :class_name => self.name, :foreign_key => :parent_id, :inverse_of => :parent, :validate => false
88
+
89
+ belongs_to :parent, :class_name => self.name, :inverse_of => :children, :index => true, :validate => false
90
+
91
+ field :parent_ids, :type => Array, :default => []
92
+ index :parent_ids => 1
93
+
94
+ set_callback :save, :after, :rearrange_children, :if => :rearrange_children?
95
+ set_callback :validation, :before do
96
+ run_callbacks(:rearrange) { rearrange }
97
+ end
98
+
99
+ validate :position_in_tree
100
+
101
+ define_model_callbacks :rearrange, :only => [:before, :after]
102
+
103
+ class_eval "def base_class; ::#{self.name}; end"
104
+ end
105
+
106
+ ##
107
+ # This module implements class methods that will be available
108
+ # on the document that includes Mongoid::Tree
109
+ module ClassMethods
110
+
111
+ ##
112
+ # Returns the first root document
113
+ #
114
+ # @example
115
+ # Node.root
116
+ #
117
+ # @return [Mongoid::Document] The first root document
118
+ def root
119
+ roots.first
120
+ end
121
+
122
+ ##
123
+ # Returns all root documents
124
+ #
125
+ # @example
126
+ # Node.roots
127
+ #
128
+ # @return [Mongoid::Criteria] Mongoid criteria to retrieve all root documents
129
+ def roots
130
+ where(:parent_id => nil)
131
+ end
132
+
133
+ ##
134
+ # Returns all leaves (be careful, currently involves two queries)
135
+ #
136
+ # @example
137
+ # Node.leaves
138
+ #
139
+ # @return [Mongoid::Criteria] Mongoid criteria to retrieve all leave nodes
140
+ def leaves
141
+ where(:_id.nin => only(:parent_id).collect(&:parent_id))
142
+ end
143
+
144
+ end
145
+
146
+ ##
147
+ # @!method before_rearrange
148
+ # @!scope class
149
+ #
150
+ # Sets a callback that is called before the document is rearranged
151
+ #
152
+ # @example
153
+ # class Node
154
+ # include Mongoid::Document
155
+ # include Mongoid::Tree
156
+ #
157
+ # before_rearrage :do_something
158
+ #
159
+ # private
160
+ #
161
+ # def do_something
162
+ # # ...
163
+ # end
164
+ # end
165
+ #
166
+ # @note Generated by ActiveSupport
167
+ #
168
+ # @return [undefined]
169
+
170
+ ##
171
+ # @!method after_rearrange
172
+ # @!scope class
173
+ #
174
+ # Sets a callback that is called after the document is rearranged
175
+ #
176
+ # @example
177
+ # class Node
178
+ # include Mongoid::Document
179
+ # include Mongoid::Tree
180
+ #
181
+ # after_rearrange :do_something
182
+ #
183
+ # private
184
+ #
185
+ # def do_something
186
+ # # ...
187
+ # end
188
+ # end
189
+ #
190
+ # @note Generated by ActiveSupport
191
+ #
192
+ # @return [undefined]
193
+
194
+ ##
195
+ # @!method children
196
+ # Returns a list of the document's children. It's a <tt>references_many</tt> association.
197
+ #
198
+ # @note Generated by Mongoid
199
+ #
200
+ # @return [Mongoid::Criteria] Mongoid criteria to retrieve the document's children
201
+
202
+ ##
203
+ # @!method parent
204
+ # Returns the document's parent (unless it's a root document). It's a <tt>referenced_in</tt> association.
205
+ #
206
+ # @note Generated by Mongoid
207
+ #
208
+ # @return [Mongoid::Document] The document's parent document
209
+
210
+ ##
211
+ # @!method parent=(document)
212
+ # Sets this documents parent document.
213
+ #
214
+ # @note Generated by Mongoid
215
+ #
216
+ # @param [Mongoid::Tree] document
217
+
218
+ ##
219
+ # @!method parent_ids
220
+ # Returns a list of the document's parent_ids, starting with the root node.
221
+ #
222
+ # @note Generated by Mongoid
223
+ #
224
+ # @return [Array<BSON::ObjectId>] The ids of the document's ancestors
225
+
226
+ ##
227
+ # Is this document a root node (has no parent)?
228
+ #
229
+ # @return [Boolean] Whether the document is a root node
230
+ def root?
231
+ parent_id.nil?
232
+ end
233
+
234
+ ##
235
+ # Is this document a leaf node (has no children)?
236
+ #
237
+ # @return [Boolean] Whether the document is a leaf node
238
+ def leaf?
239
+ children.empty?
240
+ end
241
+
242
+ ##
243
+ # Returns the depth of this document (number of ancestors)
244
+ #
245
+ # @example
246
+ # Node.root.depth # => 0
247
+ # Node.root.children.first.depth # => 1
248
+ #
249
+ # @return [Fixnum] Depth of this document
250
+ def depth
251
+ parent_ids.count
252
+ end
253
+
254
+ ##
255
+ # Returns this document's root node. Returns `self` if the
256
+ # current document is a root node
257
+ #
258
+ # @example
259
+ # node = Node.find(...)
260
+ # node.root
261
+ #
262
+ # @return [Mongoid::Document] The documents root node
263
+ def root
264
+ if parent_ids.present?
265
+ base_class.find(parent_ids.first)
266
+ else
267
+ self.root? ? self : self.parent.root
268
+ end
269
+ end
270
+
271
+ ##
272
+ # Returns a chainable criteria for this document's ancestors
273
+ #
274
+ # @return [Mongoid::Criteria] Mongoid criteria to retrieve the documents ancestors
275
+ def ancestors
276
+ if parent_ids.any?
277
+ base_class.and({
278
+ '$or' => parent_ids.map { |id| { :_id => id } }
279
+ })
280
+ else
281
+ base_class.where(:_id.in => [])
282
+ end
283
+ end
284
+
285
+ ##
286
+ # Returns an array of this document's ancestors and itself
287
+ #
288
+ # @return [Array<Mongoid::Document>] Array of the document's ancestors and itself
289
+ def ancestors_and_self
290
+ ancestors + [self]
291
+ end
292
+
293
+ ##
294
+ # Is this document an ancestor of the other document?
295
+ #
296
+ # @param [Mongoid::Tree] other document to check against
297
+ #
298
+ # @return [Boolean] The document is an ancestor of the other document
299
+ def ancestor_of?(other)
300
+ other.parent_ids.include?(self.id)
301
+ end
302
+
303
+ ##
304
+ # Returns a chainable criteria for this document's descendants
305
+ #
306
+ # @return [Mongoid::Criteria] Mongoid criteria to retrieve the document's descendants
307
+ def descendants
308
+ base_class.where(:parent_ids => self.id)
309
+ end
310
+
311
+ ##
312
+ # Returns and array of this document and it's descendants
313
+ #
314
+ # @return [Array<Mongoid::Document>] Array of the document itself and it's descendants
315
+ def descendants_and_self
316
+ [self] + descendants
317
+ end
318
+
319
+ ##
320
+ # Is this document a descendant of the other document?
321
+ #
322
+ # @param [Mongoid::Tree] other document to check against
323
+ #
324
+ # @return [Boolean] The document is a descendant of the other document
325
+ def descendant_of?(other)
326
+ self.parent_ids.include?(other.id)
327
+ end
328
+
329
+ ##
330
+ # Returns this document's siblings
331
+ #
332
+ # @return [Mongoid::Criteria] Mongoid criteria to retrieve the document's siblings
333
+ def siblings
334
+ siblings_and_self.excludes(:id => self.id)
335
+ end
336
+
337
+ ##
338
+ # Returns this document's siblings and itself
339
+ #
340
+ # @return [Mongoid::Criteria] Mongoid criteria to retrieve the document's siblings and itself
341
+ def siblings_and_self
342
+ base_class.where(:parent_id => self.parent_id)
343
+ end
344
+
345
+ ##
346
+ # Is this document a sibling of the other document?
347
+ #
348
+ # @param [Mongoid::Tree] other document to check against
349
+ #
350
+ # @return [Boolean] The document is a sibling of the other document
351
+ def sibling_of?(other)
352
+ self.parent_id == other.parent_id
353
+ end
354
+
355
+ ##
356
+ # Returns all leaves of this document (be careful, currently involves two queries)
357
+ #
358
+ # @return [Mongoid::Criteria] Mongoid criteria to retrieve the document's leaves
359
+ def leaves
360
+ base_class.where(:_id.nin => base_class.only(:parent_id).collect(&:parent_id)).and(:parent_ids => self.id)
361
+ end
362
+
363
+ ##
364
+ # Forces rearranging of all children after next save
365
+ #
366
+ # @return [undefined]
367
+ def rearrange_children!
368
+ @rearrange_children = true
369
+ end
370
+
371
+ ##
372
+ # Will the children be rearranged after next save?
373
+ #
374
+ # @return [Boolean] Whether the children will be rearranged
375
+ def rearrange_children?
376
+ !!@rearrange_children
377
+ end
378
+
379
+ ##
380
+ # Nullifies all children's parent_id
381
+ #
382
+ # @return [undefined]
383
+ def nullify_children
384
+ children.each do |c|
385
+ c.parent = c.parent_id = nil
386
+ c.save
387
+ end
388
+ end
389
+
390
+ ##
391
+ # Moves all children to this document's parent
392
+ #
393
+ # @return [undefined]
394
+ def move_children_to_parent
395
+ children.each do |c|
396
+ c.parent = self.parent
397
+ c.save
398
+ end
399
+ end
400
+
401
+ ##
402
+ # Deletes all descendants using the database (doesn't invoke callbacks)
403
+ #
404
+ # @return [undefined]
405
+ def delete_descendants
406
+ base_class.delete_all(:conditions => { :parent_ids => self.id })
407
+ end
408
+
409
+ ##
410
+ # Destroys all children by calling their #destroy method (does invoke callbacks)
411
+ #
412
+ # @return [undefined]
413
+ def destroy_children
414
+ children.destroy_all
415
+ end
416
+
417
+ private
418
+
419
+ ##
420
+ # Updates the parent_ids and marks the children for
421
+ # rearrangement when the parent_ids changed
422
+ #
423
+ # @private
424
+ # @return [undefined]
425
+ def rearrange
426
+ if self.parent_id
427
+ self.parent_ids = parent.parent_ids + [self.parent_id]
428
+ else
429
+ self.parent_ids = []
430
+ end
431
+ rearrange_children! if self.parent_ids_changed?
432
+ end
433
+
434
+ def rearrange_children
435
+ @rearrange_children = false
436
+ self.children.each { |c| c.save }
437
+ end
438
+
439
+ def position_in_tree
440
+ errors.add(:parent_id, :invalid) if self.parent_ids.include?(self.id)
441
+ end
442
+ end
443
+ end
@@ -0,0 +1,236 @@
1
+ module Mongoid
2
+ module Tree
3
+ ##
4
+ # = Mongoid::Tree::Ordering
5
+ #
6
+ # Mongoid::Tree doesn't order the tree by default. To enable ordering of children
7
+ # include both Mongoid::Tree and Mongoid::Tree::Ordering into your document.
8
+ #
9
+ # == Utility methods
10
+ #
11
+ # This module adds methods to get related siblings depending on their position:
12
+ #
13
+ # node.lower_siblings
14
+ # node.higher_siblings
15
+ # node.first_sibling_in_list
16
+ # node.last_sibling_in_list
17
+ #
18
+ # There are several methods to move nodes around in the list:
19
+ #
20
+ # node.move_up
21
+ # node.move_down
22
+ # node.move_to_top
23
+ # node.move_to_bottom
24
+ # node.move_above(other)
25
+ # node.move_below(other)
26
+ #
27
+ # Additionally there are some methods to check aspects of the document
28
+ # in the list of children:
29
+ #
30
+ # node.at_top?
31
+ # node.at_bottom?
32
+ module Ordering
33
+ extend ActiveSupport::Concern
34
+
35
+ included do
36
+ field :position, :type => Integer
37
+
38
+ default_scope asc(:position)
39
+
40
+ before_save :assign_default_position, :if => :assign_default_position?
41
+ before_save :reposition_former_siblings, :if => :sibling_reposition_required?
42
+ after_destroy :move_lower_siblings_up
43
+ end
44
+
45
+ ##
46
+ # Returns a chainable criteria for this document's ancestors
47
+ #
48
+ # @return [Mongoid::Criteria] Mongoid criteria to retrieve the document's ancestors
49
+ def ancestors
50
+ base_class.unscoped { super }
51
+ end
52
+
53
+ ##
54
+ # Returns siblings below the current document.
55
+ # Siblings with a position greater than this document's position.
56
+ #
57
+ # @return [Mongoid::Criteria] Mongoid criteria to retrieve the document's lower siblings
58
+ def lower_siblings
59
+ self.siblings.where(:position.gt => self.position)
60
+ end
61
+
62
+ ##
63
+ # Returns siblings above the current document.
64
+ # Siblings with a position lower than this document's position.
65
+ #
66
+ # @return [Mongoid::Criteria] Mongoid criteria to retrieve the document's higher siblings
67
+ def higher_siblings
68
+ self.siblings.where(:position.lt => self.position)
69
+ end
70
+
71
+ ##
72
+ # Returns siblings between the current document and the other document
73
+ # Siblings with a position between this document's position and the other document's position.
74
+ #
75
+ # @return [Mongoid::Criteria] Mongoid criteria to retrieve the documents between this and the other document
76
+ def siblings_between(other)
77
+ range = [self.position, other.position].sort
78
+ self.siblings.where(:position.gt => range.first, :position.lt => range.last)
79
+ end
80
+
81
+ ##
82
+ # Returns the lowest sibling (could be self)
83
+ #
84
+ # @return [Mongoid::Document] The lowest sibling
85
+ def last_sibling_in_list
86
+ siblings_and_self.last
87
+ end
88
+
89
+ ##
90
+ # Returns the highest sibling (could be self)
91
+ #
92
+ # @return [Mongoid::Document] The highest sibling
93
+ def first_sibling_in_list
94
+ siblings_and_self.first
95
+ end
96
+
97
+ ##
98
+ # Is this the highest sibling?
99
+ #
100
+ # @return [Boolean] Whether the document is the highest sibling
101
+ def at_top?
102
+ higher_siblings.empty?
103
+ end
104
+
105
+ ##
106
+ # Is this the lowest sibling?
107
+ #
108
+ # @return [Boolean] Whether the document is the lowest sibling
109
+ def at_bottom?
110
+ lower_siblings.empty?
111
+ end
112
+
113
+ ##
114
+ # Move this node above all its siblings
115
+ #
116
+ # @return [undefined]
117
+ def move_to_top
118
+ return true if at_top?
119
+ move_above(first_sibling_in_list)
120
+ end
121
+
122
+ ##
123
+ # Move this node below all its siblings
124
+ #
125
+ # @return [undefined]
126
+ def move_to_bottom
127
+ return true if at_bottom?
128
+ move_below(last_sibling_in_list)
129
+ end
130
+
131
+ ##
132
+ # Move this node one position up
133
+ #
134
+ # @return [undefined]
135
+ def move_up
136
+ switch_with_sibling_at_offset(-1) unless at_top?
137
+ end
138
+
139
+ ##
140
+ # Move this node one position down
141
+ #
142
+ # @return [undefined]
143
+ def move_down
144
+ switch_with_sibling_at_offset(1) unless at_bottom?
145
+ end
146
+
147
+ ##
148
+ # Move this node above the specified node
149
+ #
150
+ # This method changes the node's parent if nescessary.
151
+ #
152
+ # @param [Mongoid::Tree] other document to move this document above
153
+ #
154
+ # @return [undefined]
155
+ def move_above(other)
156
+ ensure_to_be_sibling_of(other)
157
+
158
+ if position > other.position
159
+ new_position = other.position
160
+ self.siblings_between(other).inc(:position, 1)
161
+ other.inc(:position, 1)
162
+ else
163
+ new_position = other.position - 1
164
+ self.siblings_between(other).inc(:position, -1)
165
+ end
166
+
167
+ self.position = new_position
168
+ save!
169
+ end
170
+
171
+ ##
172
+ # Move this node below the specified node
173
+ #
174
+ # This method changes the node's parent if nescessary.
175
+ #
176
+ # @param [Mongoid::Tree] other document to move this document below
177
+ #
178
+ # @return [undefined]
179
+ def move_below(other)
180
+ ensure_to_be_sibling_of(other)
181
+
182
+ if position > other.position
183
+ new_position = other.position + 1
184
+ self.siblings_between(other).inc(:position, 1)
185
+ else
186
+ new_position = other.position
187
+ self.siblings_between(other).inc(:position, -1)
188
+ other.inc(:position, -1)
189
+ end
190
+
191
+ self.position = new_position
192
+ save!
193
+ end
194
+
195
+ private
196
+
197
+ def switch_with_sibling_at_offset(offset)
198
+ siblings.where(:position => self.position + offset).first.inc(:position, -offset)
199
+ inc(:position, offset)
200
+ end
201
+
202
+ def ensure_to_be_sibling_of(other)
203
+ return if sibling_of?(other)
204
+ self.parent_id = other.parent_id
205
+ save!
206
+ end
207
+
208
+ def move_lower_siblings_up
209
+ lower_siblings.inc(:position, -1)
210
+ end
211
+
212
+ def reposition_former_siblings
213
+ former_siblings = base_class.where(:parent_id => attribute_was('parent_id')).
214
+ and(:position.gt => (attribute_was('position') || 0)).
215
+ excludes(:id => self.id)
216
+ former_siblings.inc(:position, -1)
217
+ end
218
+
219
+ def sibling_reposition_required?
220
+ parent_id_changed? && persisted?
221
+ end
222
+
223
+ def assign_default_position
224
+ self.position = if self.siblings.where(:position.ne => nil).any?
225
+ self.last_sibling_in_list.position + 1
226
+ else
227
+ 0
228
+ end
229
+ end
230
+
231
+ def assign_default_position?
232
+ self.position.nil? || self.parent_id_changed?
233
+ end
234
+ end
235
+ end
236
+ end