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 +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
|
+
|