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