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