acts_as_dag 1.2.6 → 2.0.0

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