acts_as_dag 1.0.2 → 1.0.3

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ .DS_store
2
+ *.gem
data/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ Copyright 2010 Nicholas Jakobsen and Ryan Wallace
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
@@ -0,0 +1,12 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'acts_as_dag'
3
+ s.version = '1.0.3'
4
+ s.date = %q{2010-09-15}
5
+ s.email = 'technical@rrnpilot.org'
6
+ s.homepage = 'http://github.com/rrn/acts_as_dag'
7
+ s.summary = 'Adds directed acyclic graph functionality to ActiveRecord.'
8
+ s.authors = ['Nicholas Jakobsen', 'Ryan Wallace']
9
+
10
+ s.files = `git ls-files`.split("\n")
11
+ s.require_paths = ["lib"]
12
+ end
@@ -0,0 +1,383 @@
1
+ module ActsAsDAG
2
+ def self.included(base)
3
+ def base.acts_as_dag(options = {})
4
+ link_class = "#{self.name}Link"
5
+ descendant_class = "#{self.name}Descendant"
6
+
7
+ class_eval <<-EOV
8
+ class ::#{link_class} < ActiveRecord::Base
9
+ include ActsAsDAG::LinkClassInstanceMethods
10
+
11
+ validate :not_circular_link
12
+
13
+ belongs_to :parent, :class_name => '#{self.name}', :foreign_key => 'parent_id'
14
+ belongs_to :child, :class_name => '#{self.name}', :foreign_key => 'child_id'
15
+ end
16
+
17
+ class ::#{descendant_class} < ActiveRecord::Base
18
+ belongs_to :ancestor, :class_name => '#{self.name}', :foreign_key => "ancestor_id"
19
+ belongs_to :descendant, :class_name => '#{self.name}', :foreign_key => "descendant_id"
20
+ end
21
+
22
+ def acts_as_dag_class
23
+ ::#{self.name}
24
+ end
25
+
26
+ def self.link_type
27
+ ::#{link_class}
28
+ end
29
+
30
+ def self.descendant_type
31
+ ::#{descendant_class}
32
+ end
33
+ EOV
34
+
35
+ has_many :parent_links, :class_name => link_class, :foreign_key => 'child_id', :dependent => :destroy
36
+ has_many :parents, :through => :parent_links, :source => :parent
37
+ has_many :child_links, :class_name => link_class, :foreign_key => 'parent_id', :dependent => :destroy
38
+ has_many :children, :through => :child_links, :source => :child
39
+
40
+ # Ancestors must always be returned in order of most distant to least
41
+ # Descendants must always be returned in order of least distant to most
42
+ # NOTE: multiple instances of the same descendant/ancestor may be returned if there are multiple paths from ancestor to descendant
43
+ # A
44
+ # / \
45
+ # B C
46
+ # \ /
47
+ # D
48
+ #
49
+ has_many :ancestor_links, :class_name => descendant_class, :foreign_key => 'descendant_id', :dependent => :destroy
50
+ has_many :ancestors, :through => :ancestor_links, :source => :ancestor, :order => "distance DESC"
51
+ has_many :descendant_links, :class_name => descendant_class, :foreign_key => 'ancestor_id', :dependent => :destroy
52
+ has_many :descendants, :through => :descendant_links, :source => :descendant, :order => "distance ASC"
53
+
54
+ after_create :initialize_links
55
+ after_create :initialize_descendants
56
+
57
+ scope :roots, joins(:parent_links).where(link_type.table_name => {:parent_id => nil})
58
+
59
+ extend ActsAsDAG::ClassMethods
60
+ include ActsAsDAG::InstanceMethods
61
+ end
62
+ end
63
+
64
+ module ClassMethods
65
+ def acts_like_dag?; true; end
66
+
67
+ # Reorganizes the entire class of records based on their name, first resetting the hierarchy, then reoganizing
68
+ # Can pass a list of categories and only those will be reorganized
69
+ def reorganize(categories_to_reorganize = self.all)
70
+ reset_hierarchy(categories_to_reorganize)
71
+
72
+ word_count_groups = categories_to_reorganize.group_by(&:word_count).sort
73
+ roots = word_count_groups.first[1].dup.sort_by(&:name) # We will build up a list of plinko targets, we start with the group of categories with the shortest word count
74
+
75
+ # Now plinko the next shortest word group into those targets
76
+ # If we can't plinko one, then it gets added as a root
77
+ word_count_groups[1..-1].each do |word_count, categories|
78
+ categories_with_no_parents = []
79
+
80
+ # Try drop each category into each root
81
+ categories.sort_by(&:name).each do |category|
82
+ suitable_parent = false
83
+ roots.each do |root|
84
+ suitable_parent = true if root.plinko(category)
85
+ end
86
+ unless suitable_parent
87
+ logger.info "Plinko couldn't find a suitable parent for #{category.name}"
88
+ categories_with_no_parents << category
89
+ end
90
+ end
91
+
92
+ # Add all categories from this group without suitable parents to the roots
93
+ if categories_with_no_parents.present?
94
+ logger.info "Adding #{categories_with_no_parents.collect(&:name).join(', ')} to roots"
95
+ roots.concat categories_with_no_parents
96
+ end
97
+ end
98
+ end
99
+
100
+ # Remove all hierarchy information for this category
101
+ # Can pass a list of categories to reset
102
+ def reset_hierarchy(categories_to_reset = self.all)
103
+ ids = categories_to_reset.collect(&:id)
104
+ logger.info "Clearing #{self.name} hierarchy links"
105
+ link_type.delete_all(:parent_id => ids)
106
+ link_type.delete_all(:child_id => ids)
107
+ categories_to_reset.each(&:initialize_links)
108
+
109
+ logger.info "Clearing #{self.name} hierarchy descendants"
110
+ descendant_type.delete_all(:descendant_id => ids)
111
+ descendant_type.delete_all(:ancestor_id => ids)
112
+ categories_to_reset.each(&:initialize_descendants)
113
+ end
114
+ end
115
+
116
+ module InstanceMethods
117
+ # Searches all descendants for the best parent for the other
118
+ # i.e. it lets you drop the category in at the top and it drops down the list until it finds its final resting place
119
+ def plinko(other)
120
+ if other.should_descend_from?(self)
121
+ logger.info "Plinkoing '#{other.name}' into '#{self.name}'..."
122
+
123
+ # Find the descendants of this category that +other+ should descend from
124
+ descendants_other_should_descend_from = self.descendants.select{|descendant| other.should_descend_from?(descendant)}
125
+
126
+ # Of those, find the categories with the most number of matching words and make +other+ their child
127
+ # We find all suitable candidates to provide support for categories whose names are permutations of each other
128
+ # e.g. 'goat wool fibre' should be a child of 'goat wool' and 'wool goat' if both are present under 'goat'
129
+ new_parents_group = descendants_other_should_descend_from.group_by{|category| other.matching_word_count(category)}.sort.reverse.first
130
+ if new_parents_group.present?
131
+ for new_parent in new_parents_group[1]
132
+ logger.info " '#{other.name}' landed under '#{new_parent.name}'"
133
+ other.add_parent(new_parent)
134
+
135
+ # We've just affected the associations in ways we can not possibly imagine, so let's clear the association cache
136
+ self.clear_association_cache
137
+ end
138
+ return true
139
+ end
140
+ end
141
+ end
142
+
143
+ # Convenience method for plinkoing multiple categories
144
+ # Plinko's multiple categories from shortest to longest in order to prevent the need for reorganization
145
+ def plinko_multiple(others)
146
+ groups = others.group_by(&:word_count).sort
147
+ groups.each do |word_count, categories|
148
+ categories.each do |category|
149
+ unless plinko(category)
150
+ end
151
+ end
152
+ end
153
+ end
154
+
155
+ # Adds a category as a parent of this category (self)
156
+ def add_parent(parent)
157
+ link(parent, self)
158
+ end
159
+
160
+ # Adds a category as a child of this category (self)
161
+ def add_child(child)
162
+ link(self, child)
163
+ end
164
+
165
+ # Removes a category as a child of this category (self)
166
+ # Returns the child
167
+ def remove_child(child)
168
+ unlink(self, child)
169
+ return child
170
+ end
171
+
172
+ # Removes a category as a parent of this category (self)
173
+ # Returns the parent
174
+ def remove_parent(parent)
175
+ unlink(parent, self)
176
+ return parent
177
+ end
178
+
179
+ # Returns the portion of this category's name that is not present in any of it's parents
180
+ def unique_name_portion
181
+ unique_portion = name.split
182
+ for parent in parents
183
+ for word in parent.name.split
184
+ unique_portion.delete(word)
185
+ end
186
+ end
187
+
188
+ return unique_portion.empty? ? nil : unique_portion.join(' ')
189
+ end
190
+
191
+ # Returns true if the category's descendants include *self*
192
+ def descendant_of?(category, options = {})
193
+ ancestors.exists?(category)
194
+ end
195
+
196
+ # Returns true if the category's descendants include *self*
197
+ def ancestor_of?(category, options = {})
198
+ descendants.exists?(category)
199
+ end
200
+
201
+ # Checks if self should descend from +other+ based on name matching
202
+ # Returns true if self contains all the words from +other+, but has words that are not contained in +other+
203
+ def should_descend_from?(other)
204
+ return false if self == other
205
+
206
+ other_words = other.name.split
207
+ self_words = self.name.split
208
+
209
+ # (self contains all the words from other and more) && (other contains no words that are not also in self)
210
+ return (self_words - (other_words & self_words)).count > 0 && (other_words - self_words).count == 0
211
+ end
212
+
213
+ def word_count
214
+ self.name.split.count
215
+ end
216
+
217
+ def matching_word_count(other)
218
+ other_words = other.name.split
219
+ self_words = self.name.split
220
+ return (other_words & self_words).count
221
+ end
222
+
223
+ def link_type
224
+ self.class.link_type
225
+ end
226
+
227
+ def descendant_type
228
+ self.class.descendant_type
229
+ end
230
+
231
+ # CALLBACKS
232
+ def initialize_links
233
+ link_type.new(:parent_id => nil, :child_id => id).save!
234
+ end
235
+
236
+ def initialize_descendants
237
+ descendant_type.new(:ancestor_id => id, :descendant_id => id, :distance => 0).save!
238
+ end
239
+ # END CALLBACKS
240
+
241
+ private
242
+
243
+ # LINKING FUNCTIONS
244
+
245
+ # creates a single link in the given link_type's link table between parent and
246
+ # child object ids and creates the appropriate entries in the descendant table
247
+ def link(parent, child)
248
+ # logger.info "link(hierarchy_link_table = #{link_type}, hierarchy_descendant_table = #{descendant_type}, parent = #{parent.name}, child = #{child.name})"
249
+
250
+ # Check if parent and child have id's
251
+ raise "Parent has no ID" if parent.id.nil?
252
+ raise "Child has no ID" if child.id.nil?
253
+
254
+ # Create a new parent-child link
255
+ # Return if the link already exists because we can assume that the proper descendants already exist too
256
+ if link_type.where(:parent_id => parent.id, :child_id => child.id).exists?
257
+ logger.info "Skipping #{descendant_type} update because the link already exists"
258
+ return
259
+ else
260
+ link_type.create!(:parent_id => parent.id, :child_id => child.id)
261
+ end
262
+
263
+ # If we have been passed a parent, find and destroy any existing links from nil (root) to the child as it can no longer be a top-level node
264
+ unlink(nil, child) if parent
265
+
266
+ # The parent and all its ancestors need to be added as ancestors of the child
267
+ # The child and all its descendants need to be added as descendants of the parent
268
+
269
+ # get parent ancestor id list
270
+ parent_ancestor_links = descendant_type.where(:descendant_id => parent.id) # (totem => totem pole), (totem_pole => totem_pole)
271
+ # get child descendant id list
272
+ child_descendant_links = descendant_type.where(:ancestor_id => child.id) # (totem pole model => totem pole model)
273
+ for parent_ancestor_link in parent_ancestor_links
274
+ for child_descendant_link in child_descendant_links
275
+ descendant_type.find_or_initialize_by_ancestor_id_and_descendant_id_and_distance(parent_ancestor_link.ancestor_id, child_descendant_link.descendant_id, parent_ancestor_link.distance + child_descendant_link.distance + 1).save!
276
+ end
277
+ end
278
+ end
279
+
280
+ # breaks a single link in the given hierarchy_link_table between parent and
281
+ # child object id. Updates the appropriate Descendants table entries
282
+ def unlink(parent, child)
283
+ descendant_table_string = descendant_type.to_s
284
+ # logger.info "unlink(hierarchy_link_table = #{link_type}, hierarchy_descendant_table = #{descendant_table_string}, parent = #{parent ? parent.name : 'nil'}, child = #{child.name})"
285
+
286
+ # Raise an exception if there is no child
287
+ raise "Child cannot be nil when deleting a category_link" unless child
288
+
289
+ # delete the links
290
+ link_type.delete_all(:parent_id => (parent ? parent.id : nil), :child_id => child.id)
291
+
292
+ # If the parent was nil, we don't need to update descendants because there are no descendants of nil
293
+ return unless parent
294
+
295
+ # We have unlinked C and D
296
+ # A F
297
+ # / \ /
298
+ # B C
299
+ # |
300
+ # | D
301
+ # \ /
302
+ # E
303
+ #
304
+ # Now destroy all affected descendant_links (ancestors of parent (C), descendants of child (D))
305
+ descendant_type.delete_all(:ancestor_id => parent.fast_ancestor_ids, :descendant_id => child.fast_descendant_ids)
306
+
307
+ # Now iterate through all ancestors of the descendant_links that were deleted and pick only those that have no parents, namely (A, D)
308
+ # These will be the starting points for the recreation of descendant links
309
+ starting_points = self.class.find(parent.fast_ancestor_ids + child.fast_descendant_ids).select{|node| node.parents.empty? || node.parents == [nil] }
310
+ logger.info {"starting points are #{starting_points.collect(&:name).to_sentence}" }
311
+
312
+ # POSSIBLE OPTIMIZATION: The two starting points may share descendants. We only need to process each node once, so if we could skip dups, that would be good
313
+ starting_points.each{|node| node.send(:rebuild_descendant_links)}
314
+ end
315
+
316
+ # Create a descendant link to iteself, then iterate through all children
317
+ # We add this node to the ancestor array we received
318
+ # Then we create a descendant link between it and all nodes in the array we were passed (nodes traversed between it and all its ancestors affected by the unlinking).
319
+ # Then iterate to all children of the current node passing the ancestor array along
320
+ def rebuild_descendant_links(ancestors = [])
321
+ indent = ""
322
+ ancestors.size.times do |index|
323
+ indent << " "
324
+ end
325
+
326
+ logger.info {"#{indent}Rebuilding descendant links of #{self.name}"}
327
+ # Add self to the list of traversed nodes that we will pass to the children we decide to recurse to
328
+ ancestors << self
329
+
330
+ # Create descendant links to each ancestor in the array (including itself)
331
+ ancestors.reverse.each_with_index do |ancestor, index|
332
+ logger.info {"#{indent}#{ancestor.name} is an ancestor of #{self.name} with distance #{index}"}
333
+ descendant_type.find_or_initialize_by_ancestor_id_and_descendant_id_and_distance(:ancestor_id => ancestor.id, :descendant_id => self.id, :distance => index).save!
334
+ end
335
+
336
+ # Now check each child to see if it is a descendant, or if we need to recurse
337
+ for child in children
338
+ logger.info {"#{indent}Recursing to #{child.name}"}
339
+ child.send(:rebuild_descendant_links, ancestors.dup)
340
+ end
341
+ logger.info {"#{indent}Done recursing"}
342
+ end
343
+
344
+ # END LINKING FUNCTIONS
345
+
346
+ # GARBAGE COLLECTION
347
+ # Remove all entries from this object's table that are not associated in some way with an item
348
+ def self.garbage_collect
349
+ table_prefix = self.class.name.tableize
350
+ root_locations = self.class.includes("#{table_prefix}_parents").where("#{table_prefix}_links.parent_id IS NULL")
351
+ for root_location in root_locations
352
+ root_location.garbage_collect
353
+ end
354
+ end
355
+
356
+ def garbage_collect
357
+ # call garbage collect on all children,
358
+ # Return false if any of those are unsuccessful, thus cancelling the recursion chain
359
+ for child in children
360
+ return false unless child.garbage_collect
361
+ end
362
+
363
+ if events.blank?
364
+ destroy
365
+ logger.info "Deleted RRN #{self.class} ##{id} (#{name}) during garbage collection"
366
+ return true
367
+ else
368
+ return false
369
+ end
370
+ end
371
+ # END GARBAGE COLLECTION
372
+ end
373
+
374
+ module LinkClassInstanceMethods
375
+ def not_circular_link
376
+ errors.add_to_base("Circular #{self.class} cannot be created.") if parent_id == child_id
377
+ end
378
+ end
379
+ end
380
+
381
+ if Object.const_defined?("ActiveRecord")
382
+ ActiveRecord::Base.send(:include, ActsAsDAG)
383
+ end
@@ -0,0 +1,225 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'acts_as_dag' do
4
+ before(:each) do
5
+ MyModel.destroy_all # Because we're using sqlite3 and it doesn't support transactional specs (afaik)
6
+ end
7
+
8
+ describe "Basics" do
9
+ before(:each) do
10
+ @grandpa = MyModel.create(:name => 'grandpa')
11
+ @dad = MyModel.create(:name => 'dad')
12
+ @mom = MyModel.create(:name => 'mom')
13
+ @child = MyModel.create(:name => 'child')
14
+ end
15
+
16
+ it "should be a root node immediately after saving" do
17
+ @grandpa.parents.should be_empty
18
+ end
19
+
20
+ it "should be descendant of itself immediately after saving" do
21
+ @grandpa.descendants.should == [@grandpa]
22
+ end
23
+
24
+ it "should be ancestor of itself immediately after saving" do
25
+ @grandpa.ancestors.should == [@grandpa]
26
+ end
27
+
28
+ it "should be able to add a child" do
29
+ @grandpa.add_child(@dad)
30
+
31
+ @grandpa.children.should == [@dad]
32
+ end
33
+
34
+ it "should be able to add a parent" do
35
+ @child.add_parent(@dad)
36
+
37
+ @child.parents.should == [@dad]
38
+ end
39
+
40
+ it "should be able to add multiple parents" do
41
+ @child.add_parent(@dad)
42
+ @child.add_parent(@mom)
43
+
44
+ @child.parents.sort_by(&:id).should == [@dad, @mom].sort_by(&:id)
45
+ end
46
+
47
+ it "should be able to add multiple children" do
48
+ @grandpa.add_child(@dad)
49
+ @grandpa.add_child(@mom)
50
+
51
+ @grandpa.children.sort_by(&:id).should == [@dad, @mom].sort_by(&:id)
52
+ end
53
+
54
+ it "should be able to add ancestors (top down)" do
55
+ @grandpa.add_child(@dad)
56
+ @dad.add_child(@child)
57
+
58
+ @grandpa.children.should == [@dad]
59
+ @grandpa.descendants.sort_by(&:id).should == [@grandpa, @dad, @child].sort_by(&:id)
60
+ @dad.descendants.should == [@dad, @child]
61
+ @dad.children.should == [@child]
62
+ end
63
+
64
+ it "should be able to add ancestors (bottom up)" do
65
+ @dad.add_child(@child)
66
+ @grandpa.add_child(@dad)
67
+
68
+ @grandpa.children.should == [@dad]
69
+ @grandpa.descendants.sort_by(&:id).should == [@grandpa, @dad, @child].sort_by(&:id)
70
+ @dad.descendants.should == [@dad,@child]
71
+ @dad.children.should == [@child]
72
+ end
73
+
74
+ it "should be able to test descent" do
75
+ @dad.add_child(@child)
76
+ @grandpa.add_child(@dad)
77
+
78
+ @grandpa.ancestor_of?(@child).should be_true
79
+ @child.descendant_of?(@grandpa).should be_true
80
+ @child.ancestor_of?(@grandpa).should be_false
81
+ @grandpa.descendant_of?(@child).should be_false
82
+ end
83
+ end
84
+
85
+ describe "reorganization" do
86
+ before(:each) do
87
+ @totem = MyModel.create(:name => "totem")
88
+ @totem_pole = MyModel.create(:name => "totem pole")
89
+ @big_totem_pole = MyModel.create(:name => "big totem pole")
90
+ @big_model_totem_pole = MyModel.create(:name => "big model totem pole")
91
+ @big_red_model_totem_pole = MyModel.create(:name => "big red model totem pole")
92
+ end
93
+
94
+ it "should be able to determine whether one category is an ancestor of the other by inspecting the name" do
95
+ @totem_pole.should_descend_from?(@big_totem_pole).should be_false
96
+ @big_totem_pole.should_descend_from?(@totem_pole).should be_true
97
+ end
98
+
99
+ it "should arrange the categories correctly when not passed any arguments" do
100
+ MyModel.reorganize
101
+
102
+ @totem.children.should == [@totem_pole]
103
+ @totem_pole.children.should == [@big_totem_pole]
104
+ @big_totem_pole.children.should == [@big_model_totem_pole]
105
+ @big_model_totem_pole.children.should == [@big_red_model_totem_pole]
106
+ end
107
+
108
+ it "should arrange the categories correctly when passed a set of nodes to reorganize" do
109
+ MyModel.reorganize [@totem, @totem_pole, @big_totem_pole, @big_model_totem_pole, @big_red_model_totem_pole]
110
+
111
+ @totem.reload.children.should == [@totem_pole]
112
+ @totem_pole.reload.children.should == [@big_totem_pole]
113
+ @big_totem_pole.reload.children.should == [@big_model_totem_pole]
114
+ @big_model_totem_pole.reload.children.should == [@big_red_model_totem_pole]
115
+ end
116
+
117
+ it "should arrange the categories correctly when inserting a category into an existing chain" do
118
+ @totem.add_child(@big_totem_pole)
119
+
120
+ MyModel.reorganize
121
+
122
+ @totem.children.should == [@totem_pole]
123
+ @totem_pole.children.should == [@big_totem_pole]
124
+ @big_totem_pole.children.should == [@big_model_totem_pole]
125
+ @big_model_totem_pole.reload.children.should == [@big_red_model_totem_pole]
126
+ end
127
+
128
+ it "should still work when there are categories that are permutations of each other" do
129
+ @big_totem_pole_model = MyModel.create(:name => "big totem pole model")
130
+
131
+ MyModel.reorganize [@totem, @totem_pole, @big_totem_pole, @big_model_totem_pole, @big_red_model_totem_pole, @big_totem_pole_model]
132
+
133
+ @totem.children.should == [@totem_pole]
134
+ @totem_pole.children.should == [@big_totem_pole]
135
+ (@big_totem_pole.children - [@big_model_totem_pole, @big_totem_pole_model]).should == []
136
+ @big_model_totem_pole.reload.children.should == [@big_red_model_totem_pole]
137
+ @big_totem_pole_model.reload.children.should == [@big_red_model_totem_pole]
138
+ end
139
+
140
+ describe "when there is a single long inheritance chain" do
141
+ before(:each) do
142
+ @totem.add_child(@totem_pole)
143
+ @totem_pole.add_child(@big_totem_pole)
144
+ @big_totem_pole.add_child(@big_model_totem_pole)
145
+ @big_model_totem_pole.add_child(@big_red_model_totem_pole)
146
+ end
147
+
148
+ describe "and we are reorganizing the middle of the chain" do
149
+ # Totem
150
+ # |
151
+ # Totem Pole
152
+ # *|* \
153
+ # *|* Big Totem Pole
154
+ # *|* /
155
+ # Big Model Totem Pole
156
+ # |
157
+ # Big Red Model Totem Pole
158
+ #
159
+ before(:each) do
160
+ @totem_pole.add_child(@big_model_totem_pole)
161
+ end
162
+
163
+ it "should return multiple instances of descendants before breaking the old link" do
164
+ @totem.descendants.sort_by(&:id).should == [@totem, @totem_pole, @big_totem_pole, @big_model_totem_pole, @big_model_totem_pole, @big_red_model_totem_pole, @big_red_model_totem_pole].sort_by(&:id)
165
+ end
166
+
167
+ it "should return the correct inheritance chain after breaking the old link" do
168
+ @totem_pole.remove_child(@big_model_totem_pole)
169
+
170
+ @totem_pole.children.sort_by(&:id).should == [@big_totem_pole].sort_by(&:id)
171
+ @totem.descendants.sort_by(&:id).should == [@totem, @totem_pole, @big_totem_pole, @big_model_totem_pole, @big_red_model_totem_pole].sort_by(&:id)
172
+ end
173
+
174
+ it "should return the correct inheritance chain after breaking the old link when there is are two ancestor root nodes" do
175
+ pole = MyModel.create(:name => "pole")
176
+ @totem_pole.add_parent(pole)
177
+ @totem_pole.remove_child(@big_model_totem_pole)
178
+
179
+ pole.descendants.sort_by(&:id).should == [pole, @totem_pole, @big_totem_pole, @big_model_totem_pole, @big_red_model_totem_pole].sort_by(&:id)
180
+ @totem_pole.children.sort_by(&:id).should == [@big_totem_pole].sort_by(&:id)
181
+ @totem.descendants.sort_by(&:id).should == [@totem, @totem_pole, @big_totem_pole, @big_model_totem_pole, @big_red_model_totem_pole].sort_by(&:id)
182
+ end
183
+ end
184
+ end
185
+ end
186
+
187
+ describe "and two paths of the same length exist to the same node" do
188
+ before(:each) do
189
+ @grandpa = MyModel.create(:name => 'grandpa')
190
+ @dad = MyModel.create(:name => 'dad')
191
+ @mom = MyModel.create(:name => 'mom')
192
+ @child = MyModel.create(:name => 'child')
193
+
194
+ # nevermind the incest
195
+ @grandpa.add_child(@dad)
196
+ @dad.add_child(@child)
197
+ @child.add_parent(@mom)
198
+ @mom.add_parent(@grandpa)
199
+ end
200
+
201
+ it "descendants should not return multiple instances of a child" do
202
+ @grandpa.descendants.sort_by(&:id).should == [@grandpa, @dad, @mom, @child].sort_by(&:id)
203
+ end
204
+
205
+ describe "and a link between parent and ancestor is removed" do
206
+ before(:each) do
207
+ # the incest is undone!
208
+ @dad.remove_parent(@grandpa)
209
+ end
210
+
211
+ it "should still return the correct ancestors" do
212
+ @child.ancestors.sort_by(&:id).should == [@grandpa, @dad, @mom, @child].sort_by(&:id)
213
+ @mom.ancestors.sort_by(&:id).should == [@grandpa, @mom].sort_by(&:id)
214
+ @dad.ancestors.sort_by(&:id).should == [@dad].sort_by(&:id)
215
+ end
216
+
217
+ it "should still return the correct descendants" do
218
+ @child.descendants.sort_by(&:id).should == [@child].sort_by(&:id)
219
+ @mom.descendants.sort_by(&:id).should == [@mom, @child].sort_by(&:id)
220
+ @dad.descendants.sort_by(&:id).should == [@dad, @child].sort_by(&:id)
221
+ @grandpa.descendants.sort_by(&:id).should == [@grandpa, @mom, @child].sort_by(&:id)
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,29 @@
1
+ $LOAD_PATH << File.join(File.dirname(__FILE__), '..', 'lib')
2
+ require 'active_record'
3
+ require 'logger'
4
+ require 'acts_as_dag'
5
+
6
+ ActiveRecord::Base.logger = Logger.new(STDOUT)
7
+ ActiveRecord::Base.logger.level = Logger::INFO
8
+ ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :database => ":memory:")
9
+
10
+ ActiveRecord::Schema.define(:version => 0) do
11
+ create_table :my_models, :force => true do |t|
12
+ t.string :name
13
+ end
14
+
15
+ create_table :my_model_links, :force => true do |t|
16
+ t.integer :parent_id
17
+ t.integer :child_id
18
+ end
19
+
20
+ create_table :my_model_descendants, :force => true do |t|
21
+ t.integer :ancestor_id
22
+ t.integer :descendant_id
23
+ t.integer :distance
24
+ end
25
+ end
26
+
27
+ class MyModel < ActiveRecord::Base
28
+ acts_as_dag
29
+ end
metadata CHANGED
@@ -1,57 +1,52 @@
1
- --- !ruby/object:Gem::Specification
1
+ --- !ruby/object:Gem::Specification
2
2
  name: acts_as_dag
3
- version: !ruby/object:Gem::Version
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.3
4
5
  prerelease:
5
- version: 1.0.2
6
6
  platform: ruby
7
- authors:
7
+ authors:
8
8
  - Nicholas Jakobsen
9
9
  - Ryan Wallace
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
-
14
- date: 2010-09-15 00:00:00 -07:00
15
- default_executable:
13
+ date: 2010-09-15 00:00:00.000000000 Z
16
14
  dependencies: []
17
-
18
15
  description:
19
16
  email: technical@rrnpilot.org
20
17
  executables: []
21
-
22
18
  extensions: []
23
-
24
- extra_rdoc_files:
19
+ extra_rdoc_files: []
20
+ files:
21
+ - .gitignore
22
+ - LICENSE
25
23
  - README.rdoc
26
- files:
27
- - README.rdoc
28
- has_rdoc: true
24
+ - acts_as_dag.gemspec
25
+ - lib/acts_as_dag.rb
26
+ - spec/acts_as_dag_spec.rb
27
+ - spec/spec_helper.rb
29
28
  homepage: http://github.com/rrn/acts_as_dag
30
29
  licenses: []
31
-
32
30
  post_install_message:
33
31
  rdoc_options: []
34
-
35
- require_paths:
32
+ require_paths:
36
33
  - lib
37
- required_ruby_version: !ruby/object:Gem::Requirement
34
+ required_ruby_version: !ruby/object:Gem::Requirement
38
35
  none: false
39
- requirements:
40
- - - ">="
41
- - !ruby/object:Gem::Version
42
- version: "0"
43
- required_rubygems_version: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ! '>='
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ required_rubygems_version: !ruby/object:Gem::Requirement
44
41
  none: false
45
- requirements:
46
- - - ">="
47
- - !ruby/object:Gem::Version
48
- version: "0"
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
49
46
  requirements: []
50
-
51
47
  rubyforge_project:
52
- rubygems_version: 1.6.2
48
+ rubygems_version: 1.8.15
53
49
  signing_key:
54
50
  specification_version: 3
55
51
  summary: Adds directed acyclic graph functionality to ActiveRecord.
56
52
  test_files: []
57
-