acts_as_dag 1.0.2 → 1.0.3

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/.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
-