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