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.
- data/lib/acts_as_dag/acts_as_dag.rb +388 -0
- data/lib/acts_as_dag.rb +4 -387
- data/spec/acts_as_dag_spec.rb +213 -174
- data/spec/spec_helper.rb +31 -4
- metadata +2 -1
@@ -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
|
-
|
2
|
-
|
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
|
-
|
8
|
-
|
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
|
data/spec/acts_as_dag_spec.rb
CHANGED
@@ -1,225 +1,264 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
3
|
describe 'acts_as_dag' do
|
4
|
-
|
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
|
-
@
|
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
|
-
|
17
|
-
|
18
|
-
|
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
|
-
|
21
|
-
|
22
|
-
|
22
|
+
it "should be descendant of itself immediately after saving" do
|
23
|
+
@grandpa.descendants.should == [@grandpa]
|
24
|
+
end
|
23
25
|
|
24
|
-
|
25
|
-
|
26
|
-
|
26
|
+
it "should be ancestor of itself immediately after saving" do
|
27
|
+
@grandpa.ancestors.should == [@grandpa]
|
28
|
+
end
|
27
29
|
|
28
|
-
|
29
|
-
|
30
|
+
it "should be able to add a child" do
|
31
|
+
@grandpa.add_child(@dad)
|
30
32
|
|
31
|
-
|
32
|
-
|
33
|
+
@grandpa.children.should == [@dad]
|
34
|
+
end
|
33
35
|
|
34
|
-
|
35
|
-
|
36
|
+
it "should be able to add a parent" do
|
37
|
+
@child.add_parent(@dad)
|
36
38
|
|
37
|
-
|
38
|
-
|
39
|
+
@child.parents.should == [@dad]
|
40
|
+
end
|
39
41
|
|
40
|
-
|
41
|
-
|
42
|
-
|
42
|
+
it "should be able to add multiple parents" do
|
43
|
+
@child.add_parent(@dad)
|
44
|
+
@child.add_parent(@mom)
|
43
45
|
|
44
|
-
|
45
|
-
|
46
|
+
@child.parents.sort_by(&:id).should == [@dad, @mom].sort_by(&:id)
|
47
|
+
end
|
46
48
|
|
47
|
-
|
48
|
-
|
49
|
-
|
49
|
+
it "should be able to add multiple children" do
|
50
|
+
@grandpa.add_child(@dad)
|
51
|
+
@grandpa.add_child(@mom)
|
50
52
|
|
51
|
-
|
52
|
-
|
53
|
+
@grandpa.children.sort_by(&:id).should == [@dad, @mom].sort_by(&:id)
|
54
|
+
end
|
53
55
|
|
54
|
-
|
55
|
-
|
56
|
-
|
56
|
+
it "should be able to add ancestors (top down)" do
|
57
|
+
@grandpa.add_child(@dad)
|
58
|
+
@dad.add_child(@child)
|
57
59
|
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
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
|
-
|
65
|
-
|
66
|
-
|
66
|
+
it "should be able to add ancestors (bottom up)" do
|
67
|
+
@dad.add_child(@child)
|
68
|
+
@grandpa.add_child(@dad)
|
67
69
|
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
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
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
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
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
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
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
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
|
-
|
109
|
-
|
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
|
-
|
118
|
-
|
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
|
-
|
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
|
-
|
123
|
-
|
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
|
-
|
134
|
+
@klass.reorganize
|
132
135
|
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
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
|
-
|
141
|
-
|
142
|
-
@totem.
|
143
|
-
@totem_pole.
|
144
|
-
@big_totem_pole.
|
145
|
-
@big_model_totem_pole.
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
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
|
-
@
|
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
|
-
|
164
|
-
|
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
|
-
|
168
|
-
|
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
|
-
|
171
|
-
|
172
|
-
|
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
|
-
|
175
|
-
|
176
|
-
|
177
|
-
@
|
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
|
-
|
180
|
-
@
|
181
|
-
@
|
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 "
|
241
|
+
describe "models with separate link tables" do
|
188
242
|
before(:each) do
|
189
|
-
@
|
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
|
-
|
202
|
-
|
203
|
-
end
|
246
|
+
it_should_behave_like "DAG Model"
|
247
|
+
end
|
204
248
|
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
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
|
-
|
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
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
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
|
-
|
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
|
-
|
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 :
|
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
|
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
|
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
|