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 +4 -4
- data/README.md +139 -0
- data/lib/acts_as_dag.rb +2 -1
- data/lib/acts_as_dag/acts_as_dag.rb +132 -181
- data/lib/acts_as_dag/deprecated.rb +121 -0
- data/spec/acts_as_dag_spec.rb +730 -206
- data/spec/deprecated_spec.rb +128 -0
- data/spec/spec_helper.rb +3 -1
- metadata +21 -5
- data/README.rdoc +0 -28
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7cdf416ca036dc48217c3248d88ca65c9b18f5f7
|
4
|
+
data.tar.gz: 2885cb66137fed85c3c8d4e5506525b37042ab08
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
@@ -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 :
|
63
|
-
has_many :
|
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
|
-
|
68
|
-
has_many :
|
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 {
|
72
|
-
scope :
|
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 :
|
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 :
|
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
|
-
|
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
|
-
|
152
|
-
|
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(
|
157
|
-
|
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(
|
162
|
-
|
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
|
-
|
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
|
-
|
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
|
201
|
-
|
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
|
212
|
-
|
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
|
-
|
303
|
-
|
304
|
-
|
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
|
-
|
315
|
-
|
316
|
-
|
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
|
337
|
-
klass.link_table_entries.where(:parent_id => parent.try(:id), :child_id => child.id).
|
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
|
-
|
352
|
-
|
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
|
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.
|
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|
|
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.
|
368
|
-
indent = Array.new(
|
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
|
-
|
328
|
+
path << current
|
374
329
|
|
375
330
|
# Create descendant links to each ancestor in the array (including itself)
|
376
|
-
|
377
|
-
|
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
|
-
|
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.
|
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
|
|