acts_as_dag 1.0.5 → 1.1.0

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