dm-is-nested_set 0.9.2

Sign up to get free protection for your applications and to get access to all the features.
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
+