dm-is-nested_set 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2008 Sindre Aarsaether (somebee.com)
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README ADDED
@@ -0,0 +1,95 @@
1
+ dm-is-nested_set
2
+ ================
3
+
4
+ DataMapper plugin allowing the creation of nested sets from data models.
5
+ Provides all the same functionality as dm-is-tree, plus tons more! Read on.
6
+
7
+ == What is a nested set?
8
+
9
+ Nested set is a clever model for storing hierarchical data in a flat table.
10
+ Instead of (only) storing the id of the parent on each node, a nested set puts
11
+ all nodes in a clever structure (see Example below). That is what makes it
12
+ possible to get the all of the descendants (not only immediate children),
13
+ ancestors, or siblings, in one single query to the database.
14
+
15
+ The only downside to nested sets (compared to trees] is that the queries it
16
+ takes to know these things, and to move nodes around in the tree are rather
17
+ complex. That is what this plugin takes care of (+ lots of other neat stuff)!
18
+
19
+ Nested sets are a good choice for most kinds of ordered trees with more than
20
+ two levels of nesting. Very good for menus, categories, and threaded posts.
21
+
22
+ == Installation
23
+
24
+ Download and install the latest dm-more-gem. Remember to require it!
25
+
26
+ == Getting started
27
+
28
+ Coming
29
+
30
+ == Traversing the tree
31
+
32
+ Coming
33
+
34
+ == Moving nodes
35
+
36
+ Coming
37
+
38
+
39
+ == Example of a nested set
40
+
41
+ We have a nested menu of categories. The categories are as follows:
42
+
43
+ -Electronics
44
+ - Televisions
45
+ - Tube
46
+ - LCD
47
+ - Plasma
48
+ - Portable Electronics
49
+ - MP3 Players
50
+ - CD Players
51
+
52
+ In a nested set, each of these categories would have 'left' and 'right' fields,
53
+ informing about where in the set they are positioned. This can be illustrated:
54
+ _____________________________________________________________________________
55
+ | _________________________________ __________________________________ |
56
+ | | ______ _____ ________ | | _______________ _________ | |
57
+ | | | | | | | | | | | | | | | |
58
+ 1 2 3 4 5 6 7 8 9 10 11 12 13 CD- 14 15 16
59
+ | | | Tube | | LCD | | Plasma | | | | MP3 Players | | Players | | |
60
+ | | |______| |_____| |________| | | |_______________| |_________| | |
61
+ | | | | | |
62
+ | | Televisions | | Portable Electronics | |
63
+ | |_________________________________| |__________________________________| |
64
+ | |
65
+ | Electronics |
66
+ |_____________________________________________________________________________|
67
+
68
+ All sets has a left / right value, that just says 'here do I start', and 'here
69
+ do I end'. The category 'Televisions' starts at 2, and ends at 9. We then know
70
+ that _all_ descendants of 'Televisions' reside between 2 and 9. Whats more, we
71
+ can see all categories that does not have any subcategory, by checking if their
72
+ left and right value has a gap between them. Clever huh?
73
+
74
+ Now, if we want to insert the category 'Flash' into 'MP3 Players', the new set
75
+ and left/right values would now be:
76
+ _____________________________________________________________________________
77
+ | __________________________________ |
78
+ | _________________________________ | _______________ | |
79
+ | | ______ _____ ________ | | | _________ | _________ | |
80
+ | | | | | | | | | | | | | | | | | |
81
+ 1 2 3 4 5 6 7 8 9 10 11 12 Flash 13 14 15 CD- 16 17 18
82
+ | | | Tube | | LCD | | Plasma | | | | |_________| | | Players | | |
83
+ | | |______| |_____| |________| | | | | |_________| | |
84
+ | | | | | MP3 Players | | |
85
+ | | Televisions | | |_______________| Portable El. | |
86
+ | |_________________________________| |__________________________________| |
87
+ | |
88
+ | Electronics |
89
+ |_____________________________________________________________________________|
90
+
91
+ == More about nested sets
92
+
93
+ * http://www.developersdex.com/gurus/articles/112.asp
94
+ * http://dev.mysql.com/tech-resources/articles/hierarchical-data.html
95
+ * http://www.codeproject.com/KB/database/nestedsets.aspx (nice illustrations)
data/Rakefile ADDED
@@ -0,0 +1,65 @@
1
+ require 'rubygems'
2
+ require 'spec'
3
+ require 'rake/clean'
4
+ require 'rake/gempackagetask'
5
+ require 'spec/rake/spectask'
6
+ require 'pathname'
7
+
8
+ CLEAN.include '{log,pkg}/'
9
+
10
+ spec = Gem::Specification.new do |s|
11
+ s.name = 'dm-is-nested_set'
12
+ s.version = '0.9.2'
13
+ s.platform = Gem::Platform::RUBY
14
+ s.has_rdoc = true
15
+ s.extra_rdoc_files = %w[ README LICENSE TODO ]
16
+ s.summary = 'DataMapper plugin allowing the creation of nested sets from data models'
17
+ s.description = s.summary
18
+ s.author = 'Sindre Aarsaether'
19
+ s.email = 'sindre@identu.no'
20
+ s.homepage = 'http://github.com/sam/dm-more/tree/master/dm-is-nested_set'
21
+ s.require_path = 'lib'
22
+ s.files = FileList[ '{lib,spec}/**/*.rb', 'spec/spec.opts', 'Rakefile', *s.extra_rdoc_files ]
23
+ s.add_dependency('dm-core', "=#{s.version}")
24
+ end
25
+
26
+ task :default => [ :spec ]
27
+
28
+ WIN32 = (RUBY_PLATFORM =~ /win32|mingw|cygwin/) rescue nil
29
+ SUDO = WIN32 ? '' : ('sudo' unless ENV['SUDOLESS'])
30
+
31
+ Rake::GemPackageTask.new(spec) do |pkg|
32
+ pkg.gem_spec = spec
33
+ end
34
+
35
+ desc "Install #{spec.name} #{spec.version} (default ruby)"
36
+ task :install => [ :package ] do
37
+ sh "#{SUDO} gem install --local pkg/#{spec.name}-#{spec.version} --no-update-sources", :verbose => false
38
+ end
39
+
40
+ desc "Uninstall #{spec.name} #{spec.version} (default ruby)"
41
+ task :uninstall => [ :clobber ] do
42
+ sh "#{SUDO} gem uninstall #{spec.name} -v#{spec.version} -I -x", :verbose => false
43
+ end
44
+
45
+ namespace :jruby do
46
+ desc "Install #{spec.name} #{spec.version} with JRuby"
47
+ task :install => [ :package ] do
48
+ sh %{#{SUDO} jruby -S gem install --local pkg/#{spec.name}-#{spec.version} --no-update-sources}, :verbose => false
49
+ end
50
+ end
51
+
52
+ desc 'Run specifications'
53
+ Spec::Rake::SpecTask.new(:spec) do |t|
54
+ t.spec_opts << '--options' << 'spec/spec.opts' if File.exists?('spec/spec.opts')
55
+ t.spec_files = Pathname.glob(Pathname.new(__FILE__).dirname + 'spec/**/*_spec.rb')
56
+
57
+ begin
58
+ t.rcov = ENV.has_key?('NO_RCOV') ? ENV['NO_RCOV'] != 'true' : true
59
+ t.rcov_opts << '--exclude' << 'spec'
60
+ t.rcov_opts << '--text-summary'
61
+ t.rcov_opts << '--sort' << 'coverage' << '--sort-reverse'
62
+ rescue Exception
63
+ # rcov not installed
64
+ end
65
+ end
data/TODO ADDED
@@ -0,0 +1,9 @@
1
+ TODO
2
+ ====
3
+ * Add support for more than one root, and scope/namespacing
4
+ * Write docs throughout the plugin
5
+ * Add support for transactions and tablelocking when reorganizing items
6
+ * Add function for (re)building nested set from ordinary tree
7
+ * Caching the finder-methods
8
+ * Allow options to pass through finders
9
+ * Handle children of destroyed objects
@@ -0,0 +1,10 @@
1
+ require 'rubygems'
2
+ require 'pathname'
3
+
4
+ gem 'dm-core', '=0.9.2'
5
+ require 'dm-core'
6
+
7
+ gem 'dm-adjust', '=0.9.2'
8
+ require 'dm-adjust'
9
+
10
+ require Pathname(__FILE__).dirname.expand_path / 'dm-is-nested_set' / 'is' / 'nested_set.rb'
@@ -0,0 +1,388 @@
1
+ module DataMapper
2
+ module Is
3
+ module NestedSet
4
+
5
+ ##
6
+ # docs in the works
7
+ #
8
+ def is_nested_set(options={})
9
+ options = { :child_key => [:parent_id], :scope => [] }.merge(options)
10
+
11
+ extend DataMapper::Is::NestedSet::ClassMethods
12
+ include DataMapper::Is::NestedSet::InstanceMethods
13
+
14
+ @nested_set_scope = options[:scope]
15
+ @nested_set_parent = options[:child_key]
16
+
17
+ property :lft, Integer, :writer => :private
18
+ property :rgt, Integer, :writer => :private
19
+
20
+ # a temporary fix. I need to filter. now I just use parent.children in self_and_siblings, which could
21
+ # be cut down to 1 instead of 2 queries. this would be the other way, but seems hackish:
22
+ # options[:child_key].each{|pname| property(pname, Integer) unless properties.detect{|p| p.name == pname}}
23
+
24
+ belongs_to :parent, :class_name => self.name, :child_key => options[:child_key], :order => [:lft.asc]
25
+ has n, :children, :class_name => self.name, :child_key => options[:child_key], :order => [:lft.asc]
26
+
27
+ #before :create do
28
+ # # scenarios:
29
+ # # - user creates a new object and does not specify a parent
30
+ # # - user creates a new object with a direct reference to a parent
31
+ # # - user spawnes a new object, and then moves it to a position
32
+ # if !self.parent
33
+ # self.class.root ? self.move_without_saving(:into => self.class.root) : self.move_without_saving(:to => 1)
34
+ # # if this is actually root, it will not move a bit (as lft is already 1)
35
+ # elsif self.parent && !self.lft
36
+ # # user has set a parent before saving (and without moving it anywhere). just move into that, and continue
37
+ # # might be som problems here if the referenced parent is not saved.
38
+ # self.move_without_saving(:into => self.parent)
39
+ # end
40
+ #end
41
+
42
+ before :save do
43
+ if self.new_record?
44
+ if !self.parent
45
+ # TODO must change for nested sets
46
+ self.root ? self.move_without_saving(:into => self.root) : self.move_without_saving(:to => 1)
47
+ elsif self.parent && !self.lft
48
+ self.move_without_saving(:into => self.parent)
49
+ end
50
+ else
51
+
52
+ if self.nested_set_scope != self.original_nested_set_scope
53
+ # TODO detach from old list first. many edge-cases here, need good testing
54
+ self.lft,self.rgt = nil,nil
55
+ #puts "#{self.root.inspect} - #{[self.nested_set_scope,self.original_nested_set_scope].inspect}"
56
+ self.root ? self.move_without_saving(:into => self.root) : self.move_without_saving(:to => 1)
57
+ elsif (self.parent && !self.lft) || (self.parent != self.ancestor)
58
+ # if the parent is set, we try to move this into that parent, otherwise move into root.
59
+ self.parent ? self.move_without_saving(:into => self.parent) : self.move_without_saving(:into => self.class.root)
60
+ end
61
+
62
+ end
63
+ end
64
+
65
+ #before :update do
66
+ # # scenarios:
67
+ # # - user moves the object to a position
68
+ # # - user has changed the parent
69
+ # # - user has removed any reference to a parent
70
+ # # - user sets the parent_id to something, and then use #move before saving
71
+ # if (self.parent && !self.lft) || (self.parent != self.ancestor)
72
+ # # if the parent is set, we try to move this into that parent, otherwise move into root.
73
+ # self.parent ? self.move_without_saving(:into => self.parent) : self.move_without_saving(:into => self.class.root)
74
+ # end
75
+ #end
76
+
77
+ end
78
+
79
+ module ClassMethods
80
+ attr_reader :nested_set_scope, :nested_set_parent
81
+
82
+ def adjust_gap!(scoped_set,at,adjustment)
83
+ scoped_set.all(:rgt.gt => at).adjust!({:rgt => adjustment},true)
84
+ scoped_set.all(:lft.gt => at).adjust!({:lft => adjustment},true)
85
+ end
86
+
87
+ ##
88
+ # get the root of the tree. if sets are scoped, this will return false
89
+ #
90
+ def root
91
+ # TODO scoping
92
+ # what should this return if there is a scope? always false, or node if there is only one?
93
+ roots.length > 1 ? false : roots.first
94
+ end
95
+
96
+ ##
97
+ # not implemented
98
+ #
99
+ def roots
100
+ # TODO scoping
101
+ # TODO supply filtering-option?
102
+ all(nested_set_parent.zip([]).to_hash)
103
+ end
104
+
105
+ ##
106
+ #
107
+ #
108
+ def leaves
109
+ # TODO scoping, how should it act?
110
+ # TODO supply filtering-option?
111
+ all(:conditions => ["rgt=lft+1"], :order => [:lft.asc])
112
+ end
113
+
114
+ ##
115
+ # rebuilds the parent/child relationships (parent_id) from nested set (left/right values)
116
+ #
117
+ def rebuild_tree_from_set
118
+ all.each do |node|
119
+ node.parent = node.ancestor
120
+ node.save
121
+ end
122
+ end
123
+
124
+ ##
125
+ # rebuilds the nested set using parent/child relationships and a chosen order
126
+ #
127
+ def rebuild_set_from_tree(order=nil)
128
+ # TODO pending
129
+ end
130
+ end
131
+
132
+ module InstanceMethods
133
+
134
+ ##
135
+ #
136
+ # @private
137
+ def nested_set_scope
138
+ self.class.nested_set_scope.map{|p| [p,attribute_get(p)]}.to_hash
139
+ end
140
+
141
+ ##
142
+ #
143
+ # @private
144
+ def original_nested_set_scope
145
+ # TODO commit
146
+ self.class.nested_set_scope.map{|p| [p, original_values.key?(p) ? original_values[p] : attribute_get(p)]}.to_hash
147
+ end
148
+
149
+ ##
150
+ # the whole nested set this node belongs to. served flat like a pancake!
151
+ #
152
+ def nested_set
153
+ # TODO add option for serving it as a nested array
154
+ self.class.all(nested_set_scope.merge(:order => [:lft.asc]))
155
+ end
156
+
157
+ ##
158
+ # move self / node to a position in the set. position can _only_ be changed through this
159
+ #
160
+ # @example [Usage]
161
+ # * node.move :higher # moves node higher unless it is at the top of parent
162
+ # * node.move :lower # moves node lower unless it is at the bottom of parent
163
+ # * node.move :below => other # moves this node below other resource in the set
164
+ # * node.move :into => other # same as setting a parent-relationship
165
+ #
166
+ # @param vector <Symbol, Hash> A symbol, or a key-value pair that describes the requested movement
167
+ #
168
+ # @option :higher<Symbol> move node higher
169
+ # @option :highest<Symbol> move node to the top of the list (within its parent)
170
+ # @option :lower<Symbol> move node lower
171
+ # @option :lowest<Symbol> move node to the bottom of the list (within its parent)
172
+ # @option :indent<Symbol> move node into sibling above
173
+ # @option :outdent<Symbol> move node out below its current parent
174
+ # @option :into<Resource> move node into another node
175
+ # @option :above<Resource> move node above other node
176
+ # @option :below<Resource> move node below other node
177
+ # @option :to<Integer> move node to a specific location in the nested set
178
+ #
179
+ # @return <FalseClass> returns false if it cannot move to the position, or if it is already there
180
+ # @raise <RecursiveNestingError> if node is asked to position itself into one of its descendants
181
+ # @raise <UnableToPositionError> if node is unable to calculate a new position for the element
182
+ # @see move_without_saving
183
+ def move(vector)
184
+ move_without_saving(vector)
185
+ save
186
+ end
187
+
188
+ ##
189
+ # @see move
190
+ def move_without_saving(vector)
191
+ if vector.is_a? Hash then action,object = vector.keys[0],vector.values[0] else action = vector end
192
+
193
+ changed_scope = nested_set_scope != original_nested_set_scope
194
+
195
+ position = case action
196
+ when :higher then left_sibling ? left_sibling.lft : nil # : "already at the top"
197
+ when :highest then ancestor ? ancestor.lft+1 : nil # : "is root, or has no parent"
198
+ when :lower then right_sibling ? right_sibling.rgt+1 : nil # : "already at the bottom"
199
+ when :lowest then ancestor ? ancestor.rgt : nil # : "is root, or has no parent"
200
+ when :indent then left_sibling ? left_sibling.rgt : nil # : "cannot find a sibling to indent into"
201
+ when :outdent then ancestor ? ancestor.rgt+1 : nil # : "is root, or has no parent"
202
+ when :into then object ? object.rgt : nil # : "supply an object"
203
+ when :above then object ? object.lft : nil # : "supply an object"
204
+ when :below then object ? object.rgt+1 : nil # : "supply an object"
205
+ when :to then object ? object.to_i : nil # : "supply a number"
206
+ end
207
+
208
+ ##
209
+ # raising an error whenever it couldnt move seems a bit harsh. want to return self for nesting.
210
+ # if anyone has a good idea about how it should react when it cant set a valid position,
211
+ # don't hesitate to find me in #datamapper, or send me an email at sindre -a- identu -dot- no
212
+ #
213
+ # raise UnableToPositionError unless position.is_a?(Integer) && position > 0
214
+ return false if !position || position < 1
215
+ # return false if you are trying to move this into another scope
216
+ return false if [:into, :above,:below].include?(action) && nested_set_scope != object.nested_set_scope
217
+ # if node is already in the requested position
218
+ if self.lft == position || self.rgt == position - 1
219
+ self.parent = self.ancestor # must set this again, because it might have been changed by the user before move.
220
+ return false
221
+ end
222
+
223
+ transaction do |transaction|
224
+
225
+ ##
226
+ # if this node is already positioned we need to move it, and close the gap it leaves behind etc
227
+ # otherwise we only need to open a gap in the set, and smash that buggar in
228
+ #
229
+ if self.lft && self.rgt
230
+ # raise exception if node is trying to move into one of its descendants (infinate loop, spacetime will warp)
231
+ raise RecursiveNestingError if position > self.lft && position < self.rgt
232
+ # find out how wide this node is, as we need to make a gap large enough for it to fit in
233
+ gap = self.rgt - self.lft + 1
234
+ # make a gap at position, that is as wide as this node
235
+ self.class.adjust_gap!(nested_set,position-1,gap)
236
+ # offset this node (and all its descendants) to the right position
237
+ old_position = self.lft
238
+ offset = position - old_position
239
+ nested_set.all(:rgt => self.lft..self.rgt).adjust!({:lft => offset, :rgt => offset},true)
240
+ # close the gap this movement left behind.
241
+ self.class.adjust_gap!(nested_set,old_position,-gap)
242
+ else
243
+ # make a gap where the new node can be inserted
244
+ self.class.adjust_gap!(nested_set,position-1,2)
245
+ # set the position fields
246
+ self.lft, self.rgt = position, position + 1
247
+ end
248
+
249
+ self.parent = self.ancestor
250
+
251
+ end
252
+ end
253
+
254
+ ##
255
+ # get the level of this node, where 0 is root. temporary solution
256
+ #
257
+ # @return <Integer>
258
+ def level
259
+ # TODO make a level-property that is cached and intelligently adjusted when saving objects
260
+ ancestors.length
261
+ end
262
+
263
+ ##
264
+ # get all ancestors of this node, up to (and including) self
265
+ #
266
+ # @return <Collection>
267
+ def self_and_ancestors
268
+ nested_set.all(:lft.lte => lft, :rgt.gte => rgt)
269
+ end
270
+
271
+ ##
272
+ # get all ancestors of this node
273
+ #
274
+ # @return <Collection> collection of all parents, with root as first item
275
+ # @see #self_and_ancestors
276
+ def ancestors
277
+ nested_set.all(:lft.lt => lft, :rgt.gt => rgt)
278
+ #self_and_ancestors.reject{|r| r.key == self.key } # because identitymap is not used in console
279
+ end
280
+
281
+ ##
282
+ # get the parent of this node. Same as #parent, but finds it from lft/rgt instead of parent-key
283
+ #
284
+ # @return <Resource, NilClass> returns the parent-object, or nil if this is root/detached
285
+ def ancestor
286
+ ancestors.reverse.first
287
+ end
288
+
289
+ ##
290
+ # get the root this node belongs to. this will atm always be the same as Resource.root, but has a
291
+ # meaning when scoped sets is implemented
292
+ #
293
+ # @return <Resource, NilClass>
294
+ def root
295
+ nested_set.first
296
+ end
297
+
298
+ ##
299
+ # check if this node is a root
300
+ #
301
+ def root?
302
+ !parent && !new_record?
303
+ end
304
+
305
+ ##
306
+ # get all descendants of this node, including self
307
+ #
308
+ # @return <Collection> flat collection, sorted according to nested_set positions
309
+ def self_and_descendants
310
+ # TODO supply filtering-option?
311
+ nested_set.all(:lft => lft..rgt)
312
+ end
313
+
314
+ ##
315
+ # get all descendants of this node
316
+ #
317
+ # @return <Collection> flat collection, sorted according to nested_set positions
318
+ # @see #self_and_descendants
319
+ def descendants
320
+ # TODO add argument for returning as a nested array.
321
+ # TODO supply filtering-option?
322
+ nested_set.all(:lft => (lft+1)..(rgt-1))
323
+ end
324
+
325
+ ##
326
+ # get all descendants of this node that does not have any children
327
+ #
328
+ # @return <Collection>
329
+ def leaves
330
+ # TODO supply filtering-option?
331
+ nested_set.all(:lft => (lft+1)..rgt, :conditions=>["rgt=lft+1"])
332
+ end
333
+
334
+ ##
335
+ # check if this node is a leaf (does not have subnodes).
336
+ # use this instead ofdescendants.empty?
337
+ #
338
+ # @par
339
+ def leaf?
340
+ rgt-lft == 1
341
+ end
342
+
343
+ ##
344
+ # get all siblings of this node, and include self
345
+ #
346
+ # @return <Collection>
347
+ def self_and_siblings
348
+ parent ? parent.children : [self]
349
+ end
350
+
351
+ ##
352
+ # get all siblings of this node
353
+ #
354
+ # @return <Collection>
355
+ # @see #self_and_siblings
356
+ def siblings
357
+ # TODO find a way to return this as a collection?
358
+ # TODO supply filtering-option?
359
+ self_and_siblings.reject{|r| r.key == self.key } # because identitymap is not used in console
360
+ end
361
+
362
+ ##
363
+ # get sibling to the left of/above this node in the nested tree
364
+ #
365
+ # @return <Resource, NilClass> the resource to the left, or nil if self is leftmost
366
+ # @see #self_and_siblings
367
+ def left_sibling
368
+ self_and_siblings.find{|v| v.rgt == lft-1}
369
+ end
370
+
371
+ ##
372
+ # get sibling to the right of/above this node in the nested tree
373
+ #
374
+ # @return <Resource, NilClass> the resource to the right, or nil if self is rightmost
375
+ # @see #self_and_siblings
376
+ def right_sibling
377
+ self_and_siblings.find{|v| v.lft == rgt+1}
378
+ end
379
+
380
+ end
381
+
382
+ class UnableToPositionError < StandardError; end
383
+ class RecursiveNestingError < StandardError; end
384
+
385
+ Model.send(:include, self)
386
+ end # NestedSet
387
+ end # Is
388
+ end # DataMapper
@@ -0,0 +1,313 @@
1
+ require 'pathname'
2
+ require Pathname(__FILE__).dirname.expand_path.parent + 'spec_helper'
3
+
4
+ if HAS_SQLITE3 || HAS_MYSQL || HAS_POSTGRES
5
+
6
+ class User
7
+ include DataMapper::Resource
8
+
9
+ property :id, Serial
10
+ property :name, String
11
+
12
+ has n, :categories
13
+ end
14
+
15
+ class Category
16
+ include DataMapper::Resource
17
+
18
+ property :id, Integer, :serial => true
19
+ property :name, String
20
+
21
+ belongs_to :user
22
+
23
+ is :nested_set, :scope => [:user_id]
24
+
25
+
26
+
27
+ # convenience method only for speccing.
28
+ def pos; [lft,rgt] end
29
+ end
30
+
31
+ def setup
32
+ repository(:default) do
33
+
34
+ User.auto_migrate!
35
+ @paul = User.create!(:name => "paul")
36
+ @john = User.create!(:name => "john")
37
+
38
+ Category.auto_migrate!
39
+ electronics = Category.create!(:id => 1, :name => "Electronics")
40
+ televisions = Category.create!(:id => 2, :parent_id => 1, :name => "Televisions")
41
+ tube = Category.create!(:id => 3, :parent_id => 2, :name => "Tube")
42
+ lcd = Category.create!(:id => 4, :parent_id => 2, :name => "LCD")
43
+ plasma = Category.create!(:id => 5, :parent_id => 2, :name => "Plasma")
44
+ portable_el = Category.create!(:id => 6, :parent_id => 1, :name => "Portable Electronics")
45
+ mp3_players = Category.create!(:id => 7, :parent_id => 6, :name => "MP3 Players")
46
+ flash = Category.create!(:id => 8, :parent_id => 7, :name => "Flash")
47
+ cd_players = Category.create!(:id => 9, :parent_id => 6, :name => "CD Players")
48
+ radios = Category.create!(:id => 10,:parent_id => 6, :name => "2 Way Radios")
49
+ end
50
+ end
51
+
52
+ setup
53
+
54
+ # id | lft| rgt| title
55
+ #========================================
56
+ # 1 | 1 | 20 | - Electronics
57
+ # 2 | 2 | 9 | - Televisions
58
+ # 3 | 3 | 4 | - Tube
59
+ # 4 | 5 | 6 | - LCD
60
+ # 5 | 7 | 8 | - Plasma
61
+ # 6 | 10 | 19 | - Portable Electronics
62
+ # 7 | 11 | 14 | - MP3 Players
63
+ # 8 | 12 | 13 | - Flash
64
+ # 9 | 15 | 16 | - CD Players
65
+ # 10 | 17 | 18 | - 2 Way Radios
66
+
67
+ # | | | | | | | | | | | | | | | | | | | |
68
+ # 1 2 3 4 5 6 7 8 9 10 11 12 Flash 13 14 15 16 17 18 19 20
69
+ # | | | Tube | | LCD | | Plasma | | | | |___________| | | CD Players | | 2 Way Radios | | |
70
+ # | | |______| |_____| |________| | | | | |____________| |______________| | |
71
+ # | | | | | MP3 Players | | |
72
+ # | | Televisions | | |_________________| Portable Electronics | |
73
+ # | |_________________________________| |_________________________________________________________| |
74
+ # | |
75
+ # | Electronics |
76
+ # |____________________________________________________________________________________________________|
77
+
78
+
79
+
80
+ describe 'DataMapper::Is::NestedSet' do
81
+ before :all do
82
+
83
+ end
84
+
85
+ describe 'Class#rebuild_tree_from_set' do
86
+ it 'should reset all parent_ids correctly' do
87
+ repository(:default) do
88
+ plasma = Category.get(5)
89
+ plasma.parent_id.should == 2
90
+ plasma.ancestor.id.should == 2
91
+ plasma.pos.should == [7,8]
92
+ plasma.parent_id = nil
93
+ Category.rebuild_tree_from_set
94
+ plasma.parent_id.should == 2
95
+ end
96
+ end
97
+ end
98
+
99
+ describe 'Class#root and #root' do
100
+ it 'should return the toplevel node' do
101
+ Category.root.name.should == "Electronics"
102
+ end
103
+ end
104
+
105
+ describe 'Class#leaves and #leaves' do
106
+ it 'should return all nodes without descendants' do
107
+ repository(:default) do
108
+ Category.leaves.length.should == 6
109
+
110
+ r = Category.root
111
+ r.leaves.length.should == 6
112
+ r.children[1].leaves.length.should == 3
113
+ end
114
+ end
115
+ end
116
+
117
+ describe '#ancestor, #ancestors and #self_and_ancestors' do
118
+ it 'should return ancestors in an array' do
119
+ repository(:default) do |repos|
120
+ c8 = Category.get(8)
121
+ c8.ancestor.should == Category.get(7)
122
+ c8.ancestor.should == c8.parent
123
+
124
+ c8.ancestors.map{|a|a.name}.should == ["Electronics","Portable Electronics","MP3 Players"]
125
+ c8.self_and_ancestors.map{|a|a.name}.should == ["Electronics","Portable Electronics","MP3 Players","Flash"]
126
+ end
127
+ end
128
+ end
129
+
130
+ describe '#children' do
131
+ it 'should return children of node' do
132
+ repository(:default) do |repos|
133
+ r = Category.root
134
+ r.children.length.should == 2
135
+
136
+ t = r.children.first
137
+ t.children.length.should == 3
138
+ t.children.first.name.should == "Tube"
139
+ t.children[2].name.should == "Plasma"
140
+ end
141
+ end
142
+ end
143
+
144
+ describe '#descendants and #self_and_descendants' do
145
+ it 'should return all subnodes of node' do
146
+ repository(:default) do
147
+ r = Category.root
148
+ r.self_and_descendants.length.should == 10
149
+ r.descendants.length.should == 9
150
+
151
+ t = r.children[1]
152
+ t.descendants.length.should == 4
153
+ t.descendants.map{|a|a.name}.should == ["MP3 Players","Flash","CD Players","2 Way Radios"]
154
+ end
155
+ end
156
+ end
157
+
158
+ describe '#siblings and #self_and_siblings' do
159
+ it 'should return all siblings of node' do
160
+ repository(:default) do
161
+ r = Category.root
162
+ r.self_and_siblings.length.should == 1
163
+ r.descendants.length.should == 9
164
+
165
+ televisions = r.children[0]
166
+ televisions.siblings.length.should == 1
167
+ televisions.siblings.map{|a|a.name}.should == ["Portable Electronics"]
168
+ end
169
+ end
170
+ end
171
+
172
+ describe '#move' do
173
+
174
+ # Outset:
175
+ # id | lft| rgt| title
176
+ #========================================
177
+ # 1 | 1 | 20 | - Electronics
178
+ # 2 | 2 | 9 | - Televisions
179
+ # 3 | 3 | 4 | - Tube
180
+ # 4 | 5 | 6 | - LCD
181
+ # 5 | 7 | 8 | - Plasma
182
+ # 6 | 10 | 19 | - Portable Electronics
183
+ # 7 | 11 | 14 | - MP3 Players
184
+ # 8 | 12 | 13 | - Flash
185
+ # 9 | 15 | 16 | - CD Players
186
+ # 10 | 17 | 18 | - 2 Way Radios
187
+
188
+
189
+ it 'should move items correctly with :higher / :highest / :lower / :lowest' do
190
+ repository(:default) do |repos|
191
+
192
+ Category.get(4).pos.should == [5,6]
193
+
194
+ Category.get(4).move(:above => Category.get(3))
195
+ Category.get(4).pos.should == [3,4]
196
+
197
+ Category.get(4).move(:higher).should == false
198
+ Category.get(4).pos.should == [3,4]
199
+ Category.get(3).pos.should == [5,6]
200
+ Category.get(4).right_sibling.should == Category.get(3)
201
+
202
+ Category.get(4).move(:lower)
203
+ Category.get(4).pos.should == [5,6]
204
+ Category.get(4).left_sibling.should == Category.get(3)
205
+ Category.get(4).right_sibling.should == Category.get(5)
206
+
207
+ Category.get(4).move(:highest)
208
+ Category.get(4).pos.should == [3,4]
209
+ Category.get(4).move(:higher).should == false
210
+
211
+ Category.get(4).move(:lowest)
212
+ Category.get(4).pos.should == [7,8]
213
+ Category.get(4).left_sibling.should == Category.get(5)
214
+
215
+ Category.get(4).move(:higher) # should reset the tree to how it was
216
+
217
+ end
218
+ end
219
+
220
+ it 'should move items correctly with :indent / :outdent' do
221
+ repository(:default) do |repos|
222
+
223
+ mp3_players = Category.get(7)
224
+
225
+ portable_electronics = Category.get(6)
226
+ televisions = Category.get(2)
227
+
228
+ mp3_players.pos.should == [11,14]
229
+ #mp3_players.descendants.length.should == 1
230
+
231
+ # The category is at the top of its parent, should not be able to indent.
232
+ mp3_players.move(:indent).should == false
233
+
234
+ mp3_players.move(:outdent)
235
+
236
+ mp3_players.pos.should == [16,19]
237
+ mp3_players.left_sibling.should == portable_electronics
238
+
239
+ mp3_players.move(:higher) # Move up above Portable Electronics
240
+
241
+ mp3_players.pos.should == [10,13]
242
+ mp3_players.left_sibling.should == televisions
243
+ end
244
+ end
245
+ end
246
+
247
+ describe 'moving objects with #move_* #and place_node_at' do
248
+ it 'should set left/right when choosing a parent' do
249
+ repository(:default) do |repos|
250
+ Category.auto_migrate!
251
+
252
+ c1 = Category.create!(:name => "New Electronics")
253
+
254
+ c2 = Category.create!(:name => "OLED TVs")
255
+
256
+ c1.pos.should == [1,4]
257
+ c1.root.should == c1
258
+ c2.pos.should == [2,3]
259
+
260
+ c3 = Category.create(:name => "Portable Electronics")
261
+ c3.parent=c1
262
+ c3.save
263
+
264
+ c1.pos.should == [1,6]
265
+ c2.pos.should == [2,3]
266
+ c3.pos.should == [4,5]
267
+
268
+ c3.parent=c2
269
+ c3.save
270
+
271
+ c1.pos.should == [1,6]
272
+ c2.pos.should == [2,5]
273
+ c3.pos.should == [3,4]
274
+
275
+ c3.parent=c1
276
+ c3.move(:into => c2)
277
+
278
+ c1.pos.should == [1,6]
279
+ c2.pos.should == [2,5]
280
+ c3.pos.should == [3,4]
281
+
282
+ c4 = Category.create(:name => "Tube", :parent => c2)
283
+ c5 = Category.create(:name => "Flatpanel", :parent => c2)
284
+
285
+ c1.pos.should == [1,10]
286
+ c2.pos.should == [2,9]
287
+ c3.pos.should == [3,4]
288
+ c4.pos.should == [5,6]
289
+ c5.pos.should == [7,8]
290
+
291
+ c5.move(:above => c3)
292
+ c3.pos.should == [5,6]
293
+ c4.pos.should == [7,8]
294
+ c5.pos.should == [3,4]
295
+
296
+ end
297
+ end
298
+ end
299
+
300
+ describe 'scoping' do
301
+ it 'should detach from list when changing scope' do
302
+ setup
303
+ plasma = Category.get(5)
304
+ plasma.pos.should == [7,8]
305
+ plasma.user_id = 1
306
+ plasma.save
307
+
308
+ plasma.pos.should == [1,2]
309
+ end
310
+ end
311
+
312
+ end
313
+ end
data/spec/spec.opts ADDED
@@ -0,0 +1,2 @@
1
+ --format specdoc
2
+ --colour
@@ -0,0 +1,28 @@
1
+ require 'rubygems'
2
+ gem 'rspec', '>=1.1.3'
3
+ require 'spec'
4
+ require 'pathname'
5
+ require Pathname(__FILE__).dirname.expand_path.parent + 'lib/dm-is-nested_set'
6
+
7
+ def load_driver(name, default_uri)
8
+ return false if ENV['ADAPTER'] != name.to_s
9
+
10
+ lib = "do_#{name}"
11
+
12
+ begin
13
+ gem lib, '=0.9.2'
14
+ require lib
15
+ DataMapper.setup(name, ENV["#{name.to_s.upcase}_SPEC_URI"] || default_uri)
16
+ DataMapper::Repository.adapters[:default] = DataMapper::Repository.adapters[name]
17
+ true
18
+ rescue Gem::LoadError => e
19
+ warn "Could not load #{lib}: #{e}"
20
+ false
21
+ end
22
+ end
23
+
24
+ ENV['ADAPTER'] ||= 'sqlite3'
25
+
26
+ HAS_SQLITE3 = load_driver(:sqlite3, 'sqlite3::memory:')
27
+ HAS_MYSQL = load_driver(:mysql, 'mysql://localhost/dm_core_test')
28
+ HAS_POSTGRES = load_driver(:postgres, 'postgres://postgres@localhost/dm_core_test')
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dm-is-nested_set
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.9.2
5
+ platform: ruby
6
+ authors:
7
+ - Sindre Aarsaether
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2008-06-25 00:00:00 -05:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: dm-core
17
+ version_requirement:
18
+ version_requirements: !ruby/object:Gem::Requirement
19
+ requirements:
20
+ - - "="
21
+ - !ruby/object:Gem::Version
22
+ version: 0.9.2
23
+ version:
24
+ description: DataMapper plugin allowing the creation of nested sets from data models
25
+ email: sindre@identu.no
26
+ executables: []
27
+
28
+ extensions: []
29
+
30
+ extra_rdoc_files:
31
+ - README
32
+ - LICENSE
33
+ - TODO
34
+ files:
35
+ - lib/dm-is-nested_set/is/nested_set.rb
36
+ - lib/dm-is-nested_set.rb
37
+ - spec/integration/nested_set_spec.rb
38
+ - spec/spec_helper.rb
39
+ - spec/spec.opts
40
+ - Rakefile
41
+ - README
42
+ - LICENSE
43
+ - TODO
44
+ has_rdoc: true
45
+ homepage: http://github.com/sam/dm-more/tree/master/dm-is-nested_set
46
+ post_install_message:
47
+ rdoc_options: []
48
+
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: "0"
56
+ version:
57
+ required_rubygems_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: "0"
62
+ version:
63
+ requirements: []
64
+
65
+ rubyforge_project:
66
+ rubygems_version: 1.0.1
67
+ signing_key:
68
+ specification_version: 2
69
+ summary: DataMapper plugin allowing the creation of nested sets from data models
70
+ test_files: []
71
+