acts_as_dag 1.2.6 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 76bd0a144592c855b8a2f3303ab2412e84ee8639
4
- data.tar.gz: 3aab8c46cea7f49f1ea05d1cad0a20692c73d431
3
+ metadata.gz: 7cdf416ca036dc48217c3248d88ca65c9b18f5f7
4
+ data.tar.gz: 2885cb66137fed85c3c8d4e5506525b37042ab08
5
5
  SHA512:
6
- metadata.gz: 97fd0d24855ef52d68e847b3ea7aaa8c1f10b16847c523ec36265dead0dddf5e5c5a21d9293305844c54351ea974c1ed749156ba1b07cb333a0ccd88af08872b
7
- data.tar.gz: b8bc1490c55efc9115d3951120a1418678768c1428d8a0430525818404d35b990ea50f965c8e2b7935d7d0617a7224313327984524d043db9b16af8d172952fb
6
+ metadata.gz: c913c0ea39c240b172ca8a2def8f58641b5b568cf66cd612a1872c94ee5272eb5a3b1711120c17ab3caf871ce4edb25400610c88a509fc9a3bafbfb536545d57
7
+ data.tar.gz: bc6587a3699810974d7d95a564c8986356e3b3889bf2d9baf1ac174d12bdf7dfd5cd253695033b14e5107be06022c2dbcfded1feb2f1df1028916f46dd425d53
data/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # ActsAsDAG
2
+
3
+ Adds Directed Acyclic Graph functionality to ActiveRecord
4
+
5
+ ## Getting Started
6
+
7
+ ### Gemfile
8
+
9
+ ```ruby
10
+ gem 'acts_as_dag'
11
+ ```
12
+
13
+ ### Migration
14
+
15
+ ```ruby
16
+ class CreateActsAsDagTables < ActiveRecord::Migration
17
+ def change
18
+ create_table "acts_as_dag_descendants", :force => true do |t|
19
+ t.string :category_type
20
+ t.references :ancestor
21
+ t.references :descendant
22
+ t.integer :distance
23
+ end
24
+
25
+ create_table "acts_as_dag_links", :force => true do |t|
26
+ t.string :category_type
27
+ t.references :parent
28
+ t.references :child
29
+ end
30
+ end
31
+ end
32
+ ```
33
+
34
+ ### Usage
35
+
36
+ ```ruby
37
+ class Person < ActiveRecord::Base
38
+ acts_as_dag
39
+ end
40
+
41
+
42
+ # Defining links in an attributes hash
43
+ mom = Person.new(:name => 'Mom')
44
+ grandpa = Person.create(:name => 'Grandpa', :children => [mom])
45
+ grandpa.children #=> #<ActiveRecord::Associations::CollectionProxy [#<Person id: 1, name: "mom">]>
46
+
47
+ # Linking existing records manually
48
+ suzy = Person.create(:name => 'Suzy')
49
+ mom.add_child(suzy)
50
+ mom.children #=> #<ActiveRecord::Associations::CollectionProxy [#<Person id: 3, name: "suzy">]>
51
+ ```
52
+
53
+ ## Mutators
54
+
55
+ ```
56
+ add_parent Adds the given record(s) as a parent of the receiver. Accepts multiple arguments or an array.
57
+ add_child Adds the given record(s) as a child of the receiver. Accepts multiple arguments or an array.
58
+ remove_parent Removes the given record as a parent of the receiver. Accepts a single record.
59
+ remove_child Removes the given record as a child of the receiver. Accepts a single record.
60
+ ```
61
+
62
+
63
+ ## Accessors
64
+
65
+ ```
66
+ parent Returns the parent of the record, nil for a root node
67
+ parent_id Returns the id of the parent of the record, nil for a root node
68
+ root? Returns true if the record is a root node, false otherwise
69
+ ancestor_ids Returns a list of ancestor ids, starting with the root id and ending with the parent id
70
+ ancestors Scopes the model on ancestors of the record
71
+ path_ids Returns a list the path ids, starting with the root id and ending with the node's own id
72
+ path Scopes model on path records of the record
73
+ children Scopes the model on children of the record
74
+ child_ids Returns a list of child ids
75
+ descendants Scopes the model on direct and indirect children of the record
76
+ descendant_ids Returns a list of a descendant ids
77
+ subtree Scopes the model on descendants and itself
78
+ subtree_ids Returns a list of all ids in the record's subtree
79
+ ```
80
+
81
+ ## Scopes
82
+
83
+ ```
84
+ roots Nodes without parents
85
+ leafs Nodes without children
86
+ ancestors_of(node) Ancestors of node, node can be either a record or an id
87
+ children_of(node) Children of node, node can be either a record or an id
88
+ descendants_of(node) Descendants of node, node can be either a record or an id
89
+ subtree_of(node) Subtree of node, node can be either a record or an id
90
+ ```
91
+
92
+
93
+ ## Options
94
+
95
+ The default behaviour is to store data for all classes in the same two links and descendants tables.
96
+ The category_type column is used to filter out relationships for other classes. These options can be
97
+ used to choose which classes and tables store the graph data.
98
+
99
+ ```
100
+ :link_class The name of the class to use for storing parent-child relationships. Defaults to "#{self.name}Link", e.g. PersonLink
101
+ :link_table The table the link class stores data in. Defaults to "acts_as_dag_links"
102
+ :descendant_class The name of the class to use for storing ancestor-descendant relationships. Defaults to "#{self.name}Descendant", e.g PersonDescendant
103
+ :descendant_table The table the descendant class stores data in. Defaults to "acts_as_dag_descendants"
104
+ :link_conditions Conditions to use when fetching link and descendant records. Defaults to {:category_type => self.name}, e.g. {:category_type => 'Person'}
105
+ ```
106
+
107
+ ## Future development
108
+
109
+ ### Mutators
110
+
111
+ ```
112
+ remove_parent Removes the given record(s) as a parent of the receiver. Accepts a multiple arguments or an array.
113
+ remove_child Removes the given record(s) as a child of the receiver. Accepts a multiple arguments or an array.
114
+ ```
115
+
116
+ ### Accessors
117
+
118
+ ```
119
+ root Returns the root of the tree the record is in, self for a root node
120
+ root_id Returns the id of the root of the tree the record is in
121
+ has_children? Returns true if the record has any children, false otherwise
122
+ is_childless? Returns true is the record has no children, false otherwise
123
+ siblings Scopes the model on siblings of the record, the record itself is included*
124
+ sibling_ids Returns a list of sibling ids
125
+ has_siblings? Returns true if the record's parent has more than one child
126
+ is_only_child? Returns true if the record is the only child of its parent
127
+ depth Return the depth of the node, root nodes are at depth 0
128
+ ```
129
+
130
+ ### Scopes
131
+
132
+ ```
133
+ siblings_of(node) Siblings of node, node can be either a record or an id
134
+ ```
135
+
136
+
137
+ ## Credits
138
+
139
+ Thanks you to the developers of the Ancestry gem for inspiring the list of accessors and scopes
data/lib/acts_as_dag.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # Load the acts_as_dag files
2
2
  require 'acts_as_dag/acts_as_dag'
3
+ require 'acts_as_dag/deprecated'
3
4
 
4
5
  # Load the act method
5
- ActiveRecord::Base.send :extend, ActsAsDAG::ActMethod
6
+ ActiveRecord::Base.send :extend, ActsAsDAG::ActMethod
@@ -16,14 +16,21 @@ module ActsAsDAG
16
16
  class_eval <<-EOV
17
17
  class ::#{options[:link_class]} < ActsAsDAG::AbstractLink
18
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
19
+ belongs_to :parent, :class_name => '#{self.name}', :foreign_key => :parent_id, :inverse_of => :child_links
20
+ belongs_to :child, :class_name => '#{self.name}', :foreign_key => :child_id, :inverse_of => :parent_links
21
+
22
+ after_save Proc.new {|link| HelperMethods.update_transitive_closure_for_new_link(link) }
23
+ after_destroy Proc.new {|link| HelperMethods.update_transitive_closure_for_destroyed_link(link) }
24
+
25
+ def node_class; #{self.name} end
21
26
  end
22
27
 
23
28
  class ::#{options[:descendant_class]} < ActsAsDAG::AbstractDescendant
24
29
  self.table_name = '#{options[:descendant_table]}'
25
30
  belongs_to :ancestor, :class_name => '#{self.name}', :foreign_key => :ancestor_id
26
31
  belongs_to :descendant, :class_name => '#{self.name}', :foreign_key => :descendant_id
32
+
33
+ def node_class; #{self.name} end
27
34
  end
28
35
 
29
36
  def self.link_class
@@ -56,26 +63,41 @@ module ActsAsDAG
56
63
  # \ /
57
64
  # D
58
65
  #
59
- has_many :ancestors, :through => :ancestor_links, :source => :ancestor
60
- has_many :descendants, :through => :descendant_links, :source => :descendant
66
+ has_many :ancestors, lambda { order("#{descendant_class.table_name}.distance DESC") }, :through => :ancestor_links, :source => :ancestor
67
+ has_many :descendants, lambda { order("#{descendant_class.table_name}.distance ASC") }, :through => :descendant_links, :source => :descendant
68
+
69
+ has_many :path, lambda { order("#{descendant_class.table_name}.distance DESC") }, :through => :path_links, :source => :ancestor
70
+ has_many :subtree, lambda { order("#{descendant_class.table_name}.distance ASC") }, :through => :subtree_links, :source => :descendant
71
+
72
+ has_many :ancestor_links, lambda { where(options[:link_conditions]).where("ancestor_id != descendant_id") }, :class_name => descendant_class, :foreign_key => 'descendant_id'
73
+ has_many :descendant_links, lambda { where(options[:link_conditions]).where("descendant_id != ancestor_id") }, :class_name => descendant_class, :foreign_key => 'ancestor_id'
61
74
 
62
- has_many :ancestor_links, lambda { where(options[:link_conditions]).order("distance DESC") }, :class_name => descendant_class, :foreign_key => 'descendant_id', :dependent => :delete_all
63
- has_many :descendant_links, lambda { where(options[:link_conditions]).order("distance ASC") }, :class_name => descendant_class, :foreign_key => 'ancestor_id', :dependent => :delete_all
75
+ has_many :path_links, lambda { where options[:link_conditions] }, :class_name => descendant_class, :foreign_key => 'descendant_id', :dependent => :delete_all
76
+ has_many :subtree_links, lambda { where options[:link_conditions] }, :class_name => descendant_class, :foreign_key => 'ancestor_id', :dependent => :delete_all
64
77
 
65
78
  has_many :parents, :through => :parent_links, :source => :parent
66
79
  has_many :children, :through => :child_links, :source => :child
67
- has_many :parent_links, lambda { where options[:link_conditions] }, :class_name => link_class, :foreign_key => 'child_id', :dependent => :delete_all
68
- has_many :child_links, lambda { where options[:link_conditions] }, :class_name => link_class, :foreign_key => 'parent_id', :dependent => :delete_all
80
+
81
+ has_many :parent_links, lambda { where options[:link_conditions] }, :class_name => link_class, :foreign_key => 'child_id', :dependent => :delete_all, :inverse_of => :child
82
+ has_many :child_links, lambda { where options[:link_conditions] }, :class_name => link_class, :foreign_key => 'parent_id', :dependent => :delete_all, :inverse_of => :parent
69
83
 
70
84
  # NOTE: Use select to prevent ActiveRecord::ReadOnlyRecord if the returned records are modified
71
- scope :roots, lambda { select("#{table_name}.*").joins(:parent_links).where(link_class.table_name => {:parent_id => nil}) }
72
- scope :children, lambda { select("#{table_name}.*").joins(:parent_links).where.not(link_class.table_name => {:parent_id => nil}).uniq }
85
+ scope :roots, lambda { joins(:parent_links).where(link_class.table_name => {:parent_id => nil}) }
86
+ scope :leafs, lambda { joins("LEFT OUTER JOIN #{link_class.table_name} ON #{table_name}.id = parent_id").where(link_class.table_name => {:child_id => nil}).uniq }
87
+ scope :children, lambda { joins(:parent_links).where.not(link_class.table_name => {:parent_id => nil}).uniq }
88
+ scope :parent_records, lambda { joins(:child_links).where.not(link_class.table_name => {:child_id => nil}).uniq }
89
+
90
+ scope :ancestors_of, lambda {|record| joins(:descendant_links).where("descendant_id = ?", record) }
91
+ scope :descendants_of, lambda {|record| joins(:ancestor_links).where("ancestor_id = ?", record) }
92
+ scope :path_of, lambda {|record| joins(:subtree_links).where("descendant_id = ?", record) }
93
+ scope :subtree_of, lambda {|record| joins(:path_links).where("ancestor_id = ?", record) }
73
94
 
74
- after_create :initialize_links
75
- after_create :initialize_descendants
95
+ after_create :initialize_dag
76
96
 
77
97
  extend ActsAsDAG::ClassMethods
78
98
  include ActsAsDAG::InstanceMethods
99
+ extend ActsAsDAG::Deprecated::ClassMethods
100
+ include ActsAsDAG::Deprecated::InstanceMethods
79
101
  end
80
102
  end
81
103
 
@@ -84,57 +106,17 @@ module ActsAsDAG
84
106
  true
85
107
  end
86
108
 
87
- # Reorganizes the entire class of records based on their name, first resetting the hierarchy, then reoganizing
88
- # Can pass a list of categories and only those will be reorganized
89
- def reorganize(categories_to_reorganize = self.all)
90
- return if categories_to_reorganize.empty?
91
-
92
- reset_hierarchy(categories_to_reorganize)
93
-
94
- word_count_groups = categories_to_reorganize.group_by{|category| ActsAsDAG::HelperMethods.word_count(category)}.sort
95
- 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
96
-
97
- # Now plinko the next shortest word group into those targets
98
- # If we can't plinko one, then it gets added as a root
99
- word_count_groups[1..-1].each do |word_count, categories|
100
- categories_with_no_parents = []
101
-
102
- # Try drop each category into each root
103
- categories.sort_by(&:name).each do |category|
104
- ActiveRecord::Base.benchmark "Analyze #{category.name}" do
105
- suitable_parent = false
106
- roots_categories.each do |root|
107
- suitable_parent = true if ActsAsDAG::HelperMethods.plinko(root, category)
108
- end
109
- unless suitable_parent
110
- ActiveRecord::Base.logger.info { "Plinko couldn't find a suitable parent for #{category.name}" }
111
- categories_with_no_parents << category
112
- end
113
- end
114
- end
115
-
116
- # Add all categories from this group without suitable parents to the roots
117
- if categories_with_no_parents.present?
118
- ActiveRecord::Base.logger.info { "Adding #{categories_with_no_parents.collect(&:name).join(', ')} to roots" }
119
- roots_categories.concat categories_with_no_parents
120
- end
121
- end
122
- end
123
-
124
109
  # Remove all hierarchy information for this category
125
110
  # Can pass a list of categories to reset
126
111
  def reset_hierarchy(categories_to_reset = self.all)
127
112
  ids = categories_to_reset.collect(&:id)
128
113
 
129
- ActiveRecord::Base.logger.info { "Clearing #{self.name} hierarchy links" }
130
114
  link_table_entries.where("parent_id IN (?) OR child_id IN (?)", ids, ids).delete_all
131
115
 
132
- ActiveRecord::Base.logger.info { "Clearing #{self.name} hierarchy descendants" }
133
116
  descendant_table_entries.where("descendant_id IN (?) OR ancestor_id IN (?)", ids, ids).delete_all
134
117
 
135
118
  categories_to_reset.each do |category|
136
- category.send :initialize_links
137
- category.send :initialize_descendants
119
+ category.send :initialize_dag
138
120
  end
139
121
  end
140
122
  end
@@ -142,24 +124,48 @@ module ActsAsDAG
142
124
  module InstanceMethods
143
125
  # Returns true if this record is a root node
144
126
  def root?
145
- self.class.roots.exists? self
127
+ parents.empty?
128
+ end
129
+
130
+ def leaf?
131
+ children.empty?
146
132
  end
147
133
 
148
134
  def make_root
149
135
  ancestor_links.delete_all
150
136
  parent_links.delete_all
151
- send :initialize_links
152
- send :initialize_descendants
137
+ initialize_dag
138
+ end
139
+
140
+ # NOTE: Parents that are removed will not trigger the destroy callback on their link, so we need to remove them manually
141
+ def parents=(parents)
142
+ (self.parents - parents).each do |parent_to_remove|
143
+ remove_parent(parent_to_remove)
144
+ end
145
+ super
153
146
  end
154
147
 
148
+ # NOTE: Children that are removed will not trigger the destroy callback on their link, so we need to remove them manually
149
+ def children=(children)
150
+ (self.children - children).each do |child_to_remove|
151
+ remove_child(child_to_remove)
152
+ end
153
+ super
154
+ end
155
+
156
+
155
157
  # Adds a category as a parent of this category (self)
156
- def add_parent(parent)
157
- ActsAsDAG::HelperMethods.link(parent, self)
158
+ def add_parent(*parents)
159
+ parents.flatten.each do |parent|
160
+ ActsAsDAG::HelperMethods.link(parent, self)
161
+ end
158
162
  end
159
163
 
160
164
  # Adds a category as a child of this category (self)
161
- def add_child(child)
162
- ActsAsDAG::HelperMethods.link(self, child)
165
+ def add_child(*children)
166
+ children.flatten.each do |child|
167
+ ActsAsDAG::HelperMethods.link(self, child)
168
+ end
163
169
  end
164
170
 
165
171
  # Removes a category as a child of this category (self)
@@ -176,14 +182,24 @@ module ActsAsDAG
176
182
  return parent
177
183
  end
178
184
 
185
+ # Returns true if the category's children include *self*
186
+ def child_of?(category, options = {})
187
+ category.children.exists?(id)
188
+ end
189
+
190
+ # Returns true if the category's parents include *self*
191
+ def parent_of?(category, options = {})
192
+ category.parents.exists?(id)
193
+ end
194
+
179
195
  # Returns true if the category's descendants include *self*
180
196
  def descendant_of?(category, options = {})
181
- ancestors.exists?(category)
197
+ category.descendants.exists?(id)
182
198
  end
183
199
 
184
200
  # Returns true if the category's descendants include *self*
185
201
  def ancestor_of?(category, options = {})
186
- descendants.exists?(category)
202
+ category.ancestors.exists?(id)
187
203
  end
188
204
 
189
205
  # Returns the class used for links
@@ -197,98 +213,29 @@ module ActsAsDAG
197
213
  end
198
214
 
199
215
  # Returns an array of ancestors and descendants
200
- def relatives
201
- (ancestors + descendants).uniq
216
+ def lineage
217
+ lineage_links = self.class.descendant_table_entries
218
+ .select("(CASE ancestor_id WHEN #{id} THEN descendant_id ELSE ancestor_id END) AS id, ancestor_id, descendant_id, distance")
219
+ .where('ancestor_id = :id OR descendant_id = :id', :id => id)
220
+ .where('ancestor_id != descendant_id') # Don't include self
221
+
222
+ self.class.joins("JOIN (#{lineage_links.to_sql}) lineage_links ON #{self.class.table_name}.id = lineage_links.id").order("CASE ancestor_id WHEN #{id} THEN distance ELSE -distance END") # Ensure the links are orders furthest ancestor to furthest descendant
202
223
  end
203
224
 
204
225
  private
205
226
 
206
227
  # CALLBACKS
207
- def initialize_links
208
- self.class.link_table_entries.create!(:parent_id => nil, :child_id => self.id) # Root link
209
- end
210
228
 
211
- def initialize_descendants
212
- self.class.descendant_table_entries.create!(:ancestor_id => self.id, :descendant_id => self.id, :distance => 0) # Self Descendant
229
+ def initialize_dag
230
+ subtree_links.first_or_create!(:descendant_id => self.id, :distance => 0) # Self Descendant
231
+ parent_links.first_or_create!(:parent_id => nil) # Root link
213
232
  end
214
233
  end
215
234
 
216
235
  module HelperMethods
217
- # Searches all descendants for the best parent for the other
218
- # 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
219
- def self.plinko(current, other)
220
- # ActiveRecord::Base.logger.info { "Plinkoing '#{other.name}' into '#{current.name}'..." }
221
- if should_descend_from?(current, other)
222
- # Find the descendants of the current category that +other+ should descend from
223
- descendants_other_should_descend_from = current.descendants.select{|descendant| should_descend_from?(descendant, other) }
224
- # Of those, find the categories with the most number of matching words and make +other+ their child
225
- # We find all suitable candidates to provide support for categories whose names are permutations of each other
226
- # e.g. 'goat wool fibre' should be a child of 'goat wool' and 'wool goat' if both are present under 'goat'
227
- new_parents_group = descendants_other_should_descend_from.group_by{|category| matching_word_count(other, category)}.sort.reverse.first
228
- if new_parents_group.present?
229
- for new_parent in new_parents_group[1]
230
- ActiveRecord::Base.logger.info { " '#{other.name}' landed under '#{new_parent.name}'" }
231
- other.add_parent(new_parent)
232
-
233
- # We've just affected the associations in ways we can not possibly imagine, so let's clear the association cache
234
- current.clear_association_cache
235
- end
236
- return true
237
- end
238
- end
239
- end
240
-
241
- # Convenience method for plinkoing multiple categories
242
- # Plinko's multiple categories from shortest to longest in order to prevent the need for reorganization
243
- def self.plinko_multiple(current, others)
244
- groups = others.group_by{|category| word_count(category)}.sort
245
- groups.each do |word_count, categories|
246
- categories.each do |category|
247
- unless plinko(current, category)
248
- end
249
- end
250
- end
251
- end
252
-
253
- # Returns the portion of this category's name that is not present in any of it's parents
254
- def self.unique_name_portion(current)
255
- unique_portion = current.name.split
256
- for parent in current.parents
257
- for word in parent.name.split
258
- unique_portion.delete(word)
259
- end
260
- end
261
-
262
- return unique_portion.empty? ? nil : unique_portion.join(' ')
263
- end
264
-
265
- # Checks if other should descend from +current+ based on name matching
266
- # Returns true if other contains all the words from +current+, but has words that are not contained in +current+
267
- def self.should_descend_from?(current, other)
268
- return false if current == other
269
-
270
- other_words = other.name.split
271
- current_words = current.name.split
272
-
273
- # (other contains all the words from current and more) && (current contains no words that are not also in other)
274
- return (other_words - (current_words & other_words)).count > 0 && (current_words - other_words).count == 0
275
- end
276
-
277
- def self.word_count(current)
278
- current.name.split.count
279
- end
280
-
281
- def self.matching_word_count(current, other)
282
- other_words = other.name.split
283
- self_words = current.name.split
284
- return (other_words & self_words).count
285
- end
286
-
287
236
  # creates a single link in the given link_class's link table between parent and
288
237
  # child object ids and creates the appropriate entries in the descendant table
289
238
  def self.link(parent, child)
290
- # ActiveRecord::Base.logger.info { "link(hierarchy_link_table = #{child.link_class}, hierarchy_descendant_table = #{child.descendant_class}, parent = #{parent.name}, child = #{child.name})" }
291
-
292
239
  # Sanity check
293
240
  raise "Parent has no ID" if parent.id.nil?
294
241
  raise "Child has no ID" if child.id.nil?
@@ -296,28 +243,34 @@ module ActsAsDAG
296
243
 
297
244
  klass = child.class
298
245
 
299
- # Create a new parent-child link
300
246
  # Return if the link already exists because we can assume that the proper descendants already exist too
301
- if klass.link_table_entries.where(:parent_id => parent.id, :child_id => child.id).exists?
302
- ActiveRecord::Base.logger.info { "Skipping #{child.descendant_class} update because the link already exists" }
303
- return
304
- else
305
- klass.link_table_entries.create!(:parent_id => parent.id, :child_id => child.id)
306
- end
247
+ return if klass.link_table_entries.where(:parent_id => parent.id, :child_id => child.id).exists?
248
+
249
+ # Create a new parent-child link
250
+ klass.link_table_entries.create!(:parent_id => parent.id, :child_id => child.id)
307
251
 
308
252
  # 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
309
253
  unlink(nil, child) if parent
254
+ end
255
+
256
+ def self.update_transitive_closure_for_new_link(new_link)
257
+ klass = new_link.node_class
258
+
259
+ # If we're passing :parents or :children to a new record as part of #create, transitive closure on the nested records will
260
+ # be updated before the new record's after save calls :initialize_dag. We ensure it's been initalized before we start querying
261
+ # its descendant_table or it won't appear as an ancestor or descendant until too late.
262
+ new_link.parent.send(:initialize_dag) if new_link.parent && new_link.parent.id_changed?
263
+ new_link.child.send(:initialize_dag) if new_link.child && new_link.child.id_changed?
264
+
310
265
 
311
266
  # The parent and all its ancestors need to be added as ancestors of the child
312
267
  # The child and all its descendants need to be added as descendants of the parent
268
+ ancestor_ids_and_distance = klass.descendant_table_entries.where(:descendant_id => new_link.parent_id).pluck(:ancestor_id, :distance) # (totem => totem pole), (totem_pole => totem_pole)
269
+ descendant_ids_and_distance = klass.descendant_table_entries.where(:ancestor_id => new_link.child_id).pluck(:descendant_id, :distance) # (totem pole model => totem pole model)
313
270
 
314
- # get parent ancestor id list
315
- parent_ancestor_links = klass.descendant_table_entries.where(:descendant_id => parent.id) # (totem => totem pole), (totem_pole => totem_pole)
316
- # get child descendant id list
317
- child_descendant_links = klass.descendant_table_entries.where(:ancestor_id => child.id) # (totem pole model => totem pole model)
318
- for parent_ancestor_link in parent_ancestor_links
319
- for child_descendant_link in child_descendant_links
320
- klass.descendant_table_entries.find_or_create_by!(:ancestor_id => parent_ancestor_link.ancestor_id, :descendant_id => child_descendant_link.descendant_id, :distance => parent_ancestor_link.distance + child_descendant_link.distance + 1)
271
+ ancestor_ids_and_distance.each do |ancestor_id, ancestor_distance|
272
+ descendant_ids_and_distance.each do |descendant_id, descendant_distance|
273
+ klass.descendant_table_entries.find_or_create_by!(:ancestor_id => ancestor_id, :descendant_id => descendant_id, :distance => ancestor_distance + descendant_distance + 1)
321
274
  end
322
275
  end
323
276
  end
@@ -325,20 +278,16 @@ module ActsAsDAG
325
278
  # breaks a single link in the given hierarchy_link_table between parent and
326
279
  # child object id. Updates the appropriate Descendants table entries
327
280
  def self.unlink(parent, child)
328
- descendant_table_string = child.descendant_class.to_s
329
- # ActiveRecord::Base.logger.info { "unlink(hierarchy_link_table = #{child.link_class}, hierarchy_descendant_table = #{descendant_table_string}, parent = #{parent ? parent.name : 'nil'}, child = #{child.name})" }
330
-
331
281
  # Raise an exception if there is no child
332
282
  raise "Child cannot be nil when deleting a category_link" unless child
333
283
 
334
284
  klass = child.class
335
285
 
336
- # delete the links
337
- klass.link_table_entries.where(:parent_id => parent.try(:id), :child_id => child.id).delete_all
338
-
339
- # If the parent was nil, we don't need to update descendants because there are no descendants of nil
340
- return unless parent
286
+ # delete the link if it exists
287
+ klass.link_table_entries.where(:parent_id => parent.try(:id), :child_id => child.id).destroy_all
288
+ end
341
289
 
290
+ def self.update_transitive_closure_for_destroyed_link(destroyed_link)
342
291
  # We have unlinked C and D
343
292
  # A F
344
293
  # / \ /
@@ -348,42 +297,45 @@ module ActsAsDAG
348
297
  # \ /
349
298
  # E
350
299
  #
351
- # Now destroy all affected descendant_links (ancestors of parent (C), descendants of child (D))
352
- klass.descendant_table_entries.where(:ancestor_id => parent.ancestor_ids, :descendant_id => child.descendant_ids).delete_all
300
+ klass = destroyed_link.node_class
301
+ parent = destroyed_link.parent
302
+ child = destroyed_link.child
303
+
304
+ # If the parent was nil, we don't need to update descendants because there are no descendants of nil
305
+ return unless parent
353
306
 
354
- # Now iterate through all ancestors of the descendant_links that were deleted and pick only those that have no parents, namely (A, D)
307
+ # Now destroy all affected subtree_links (ancestors of parent (C), descendants of child (D))
308
+ klass.descendant_table_entries.where(:ancestor_id => parent.path_ids, :descendant_id => child.subtree_ids).delete_all
309
+
310
+ # Now iterate through all ancestors of the subtree_links that were deleted and pick only those that have no parents, namely (A, D)
355
311
  # These will be the starting points for the recreation of descendant links
356
- starting_points = klass.find(parent.ancestor_ids + child.descendant_ids).select{|node| node.parents.empty? || node.parents == [nil] }
357
- ActiveRecord::Base.logger.info {"starting points are #{starting_points.collect(&:name).to_sentence}" }
312
+ starting_points = klass.find(parent.path_ids + child.subtree_ids).select{|node| node.parents.empty? || node.parents == [nil] }
358
313
 
359
314
  # 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
360
- starting_points.each{|node| rebuild_descendant_links(node)}
315
+ starting_points.each{|node| rebuild_subtree_links(node)}
361
316
  end
362
317
 
318
+
363
319
  # Create a descendant link to iteself, then iterate through all children
364
320
  # We add this node to the ancestor array we received
365
321
  # 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).
366
322
  # Then iterate to all children of the current node passing the ancestor array along
367
- def self.rebuild_descendant_links(current, ancestors = [])
368
- indent = Array.new(ancestors.size, " ").join
323
+ def self.rebuild_subtree_links(current, path = [])
324
+ indent = Array.new(path.size, " ").join
369
325
  klass = current.class
370
326
 
371
- ActiveRecord::Base.logger.info {"#{indent}Rebuilding descendant links of #{current.name}"}
372
327
  # Add current to the list of traversed nodes that we will pass to the children we decide to recurse to
373
- ancestors << current
328
+ path << current
374
329
 
375
330
  # Create descendant links to each ancestor in the array (including itself)
376
- ancestors.reverse.each_with_index do |ancestor, index|
377
- ActiveRecord::Base.logger.info {"#{indent}#{ancestor.name} is an ancestor of #{current.name} with distance #{index}"}
378
- klass.descendant_table_entries.find_or_create_by!(:ancestor_id => ancestor.id, :descendant_id => current.id, :distance => index)
331
+ path.reverse.each_with_index do |record, index|
332
+ klass.descendant_table_entries.find_or_create_by!(:ancestor_id => record.id, :descendant_id => current.id, :distance => index)
379
333
  end
380
334
 
381
335
  # Now check each child to see if it is a descendant, or if we need to recurse
382
336
  for child in current.children
383
- ActiveRecord::Base.logger.info {"#{indent}Recursing to #{child.name}"}
384
- rebuild_descendant_links(child, ancestors.dup)
337
+ rebuild_subtree_links(child, path.dup)
385
338
  end
386
- ActiveRecord::Base.logger.info {"#{indent}Done recursing"}
387
339
  end
388
340
  end
389
341
 
@@ -391,11 +343,10 @@ module ActsAsDAG
391
343
  class AbstractLink < ActiveRecord::Base
392
344
  self.abstract_class = true
393
345
 
394
- validates_presence_of :child_id
395
346
  validate :not_self_referential
396
347
 
397
348
  def not_self_referential
398
- errors.add_to_base("Self referential links #{self.class} cannot be created.") if parent_id == child_id
349
+ errors.add(:base, "Self referential links #{self.class} cannot be created.") if parent_id == child_id
399
350
  end
400
351
  end
401
352