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 +20 -0
- data/README +95 -0
- data/Rakefile +65 -0
- data/TODO +9 -0
- data/lib/dm-is-nested_set.rb +10 -0
- data/lib/dm-is-nested_set/is/nested_set.rb +388 -0
- data/spec/integration/nested_set_spec.rb +313 -0
- data/spec/spec.opts +2 -0
- data/spec/spec_helper.rb +28 -0
- metadata +71 -0
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,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
data/spec/spec_helper.rb
ADDED
@@ -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
|
+
|