closure_tree 1.0.0 → 2.0.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md CHANGED
@@ -16,7 +16,7 @@ Note that closure_tree is being developed for Rails 3.1.0.rc1
16
16
 
17
17
  2. Run ```bundle install```
18
18
 
19
- 3. Add ```acts_as_tree``` to your hierarchical model(s).
19
+ 3. Add ```acts_as_tree``` to your hierarchical model(s) (see the <a href="#options">available options</a>).
20
20
 
21
21
  4. Add a migration to add a ```parent_id``` column to the model you want to act_as_tree.
22
22
 
@@ -35,19 +35,19 @@ Note that closure_tree is being developed for Rails 3.1.0.rc1
35
35
  "_hierarchy". Note that by calling ```acts_as_tree```, a "virtual model" (in this case, ```TagsHierarchy```) will be added automatically, so you don't need to create it.
36
36
 
37
37
  ```ruby
38
- class CreateTagHierarchy < ActiveRecord::Migration
38
+ class CreateTagHierarchies < ActiveRecord::Migration
39
39
  def change
40
- create_table :tags_hierarchy, :id => false do |t|
40
+ create_table :tag_hierarchies, :id => false do |t|
41
41
  t.integer :ancestor_id, :null => false # ID of the parent/grandparent/great-grandparent/... tag
42
42
  t.integer :descendant_id, :null => false # ID of the target tag
43
43
  t.integer :generations, :null => false # Number of generations between the ancestor and the descendant. Parent/child = 1, for example.
44
44
  end
45
45
 
46
46
  # For "all progeny of..." selects:
47
- add_index :tags_hierarchy, [:ancestor_id, :descendant_id], :unique => true
47
+ add_index :tag_hierarchies, [:ancestor_id, :descendant_id], :unique => true
48
48
 
49
49
  # For "all ancestors of..." selects
50
- add_index :tags_hierarchy, [:descendant_id]
50
+ add_index :tag_hierarchies, [:descendant_id]
51
51
  end
52
52
  end
53
53
  ```
@@ -106,6 +106,19 @@ You can ```find``` as well as ```find_or_create``` by "ancestry paths". Ancestry
106
106
 
107
107
  Note that the other columns will be null if nodes are created, other than auto-generated columns like ID and created_at timestamp. Only the specified column will receive the path element value.
108
108
 
109
+ ### Available options
110
+ <a id="options" />
111
+
112
+ When you include ```acts_as_tree``` in your model, you can provide a hash to override the following defaults:
113
+
114
+ * ```:parent_column_name``` to override the column name of the parent foreign key in the model's table
115
+ * ```:hierarchy_table_name``` to override the hierarchy table name. This defaults to the singular name of the model + "_hierarchies".
116
+ * ```:name_column``` used by #```find_or_create_by_path```, #```find_by_path```, and ```ancestry_path``` instance methods. This is primarily useful if the model only has one required field (like a "tag").
117
+ * ```:dependent``` determines what happens when a node is destroyed. Defaults to ```nil```.
118
+ * ```nil``` will simply set the parent column to null. Each child node will be considered a "root" node
119
+ * ```:delete_all``` will delete all descendant nodes (which circumvents the destroy hooks)
120
+ * ```:destroy``` will destroy all descendant nodes (which runs the destroy hooks on each child node)
121
+
109
122
  ## Accessing Data
110
123
 
111
124
  ### Class methods
@@ -121,15 +134,26 @@ Note that the other columns will be null if nodes are created, other than auto-g
121
134
  * ``` tag.child?``` returns true if this is a child node. It has a parent.
122
135
  * ``` tag.leaf?``` returns true if this is a leaf node. It has no children.
123
136
  * ``` tag.leaves``` returns an array of all the nodes in self_and_descendants that are leaves.
124
- * ``` tag.level``` returns the level, or "generation", for this node in the tree. A root node = 0
125
- * ``` tag.parent``` returns the node's immediate parent
126
- * ``` tag.children``` returns an array of immediate children (just those in the next level).
127
- * ``` tag.ancestors``` returns an array of all parents, parents' parents, etc, excluding self.
128
- * ``` tag.self_and_ancestors``` returns an array of all parents, parents' parents, etc, including self.
137
+ * ``` tag.level``` returns the level, or "generation", for this node in the tree. A root node == 0.
138
+ * ``` tag.parent``` returns the node's immediate parent. Root nodes will return nil.
139
+ * ``` tag.children``` returns an array of immediate children (just those nodes whose parent is the current node).
140
+ * ``` tag.ancestors``` returns an array of [ parent, grandparent, great grandparent, ... ]. Note that the size of this array will always equal ```tag.level```.
141
+ * ``` tag.self_and_ancestors``` returns an array of self, parent, grandparent, great grandparent, etc.
129
142
  * ``` tag.siblings``` returns an array of brothers and sisters (all at that level), excluding self.
130
143
  * ``` tag.self_and_siblings``` returns an array of brothers and sisters (all at that level), including self.
131
144
  * ``` tag.descendants``` returns an array of all children, childrens' children, etc., excluding self.
132
145
  * ``` tag.self_and_descendants``` returns an array of all children, childrens' children, etc., including self.
146
+ * ``` tag.reparent``` lets you move a node (and all it's children) to a new parent.
147
+ * ``` tag.destroy``` will destroy a node as well as possibly all of its children. See the ```:dependent``` option passed to ```acts_as_tree```.
148
+
149
+ ## Changelog
150
+
151
+ ### 2.0.0.beta1
152
+
153
+ * Had to increment the major version, as rebuild! will need to be called by prior consumers to support the new ```leaves``` class and instance methods.
154
+ * Tag deletion is supported now along with ```:dependent => :destroy``` and ```:dependent => :delete_all```
155
+ * Added new instance method ```reparent```
156
+ * Switched from default rails plugin directory structure to rspec
133
157
 
134
158
  ## Thanks to
135
159
 
data/Rakefile CHANGED
@@ -4,22 +4,14 @@ rescue LoadError
4
4
  puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
5
  end
6
6
 
7
- Bundler::GemHelper.install_tasks
8
-
9
7
  require 'yard'
10
-
11
8
  YARD::Rake::YardocTask.new do |t|
12
- t.files = ['lib/**/*.rb', 'README.md']
9
+ t.files = ['lib/**/*.rb', 'README.md']
13
10
  end
14
11
 
15
- require 'rake/testtask'
16
-
17
- Rake::TestTask.new(:test) do |t|
18
- t.libs << 'lib'
19
- t.libs << 'test'
20
- t.pattern = 'test/**/*_test.rb'
21
- t.verbose = false
22
- end
12
+ Bundler::GemHelper.install_tasks
23
13
 
14
+ require "rspec/core/rake_task"
15
+ RSpec::Core::RakeTask.new(:spec)
24
16
 
25
- task :default => :test
17
+ task :default => :spec
@@ -1,12 +1,12 @@
1
- module ClosureTree #:nodoc:
2
- module ActsAsTree #:nodoc:
3
- def acts_as_tree options = {}
1
+ module ClosureTree
2
+ module ActsAsTree
3
+ def acts_as_tree(options = {})
4
4
 
5
5
  class_attribute :closure_tree_options
6
+
6
7
  self.closure_tree_options = {
7
8
  :parent_column_name => 'parent_id',
8
- :dependent => :delete_all, # or :destroy
9
- :hierarchy_table_suffix => '_hierarchies',
9
+ :dependent => :nullify, # or :destroy or :delete_all -- see the README
10
10
  :name_column => 'name'
11
11
  }.merge(options)
12
12
 
@@ -25,53 +25,55 @@ module ClosureTree #:nodoc:
25
25
 
26
26
  include ClosureTree::Model
27
27
 
28
- belongs_to :parent, :class_name => base_class.to_s,
29
- :foreign_key => parent_column_name
28
+ before_destroy :acts_as_tree_before_destroy
29
+ before_save :acts_as_tree_before_save
30
+ after_save :acts_as_tree_after_save
31
+
32
+ belongs_to :parent,
33
+ :class_name => base_class.to_s,
34
+ :foreign_key => parent_column_name
30
35
 
31
36
  has_many :children,
32
- :class_name => base_class.to_s,
33
- :foreign_key => parent_column_name,
34
- :before_add => :add_child
35
-
36
- has_and_belongs_to_many :ancestors,
37
- :class_name => base_class.to_s,
38
- :join_table => hierarchy_table_name,
39
- :foreign_key => "descendant_id",
40
- :association_foreign_key => "ancestor_id",
41
- :order => "generations asc"
42
-
43
- has_and_belongs_to_many :descendants,
44
- :class_name => base_class.to_s,
45
- :join_table => hierarchy_table_name,
46
- :foreign_key => "ancestor_id",
47
- :association_foreign_key => "descendant_id",
48
- :order => "generations asc"
37
+ :class_name => base_class.to_s,
38
+ :foreign_key => parent_column_name,
39
+ :dependent => closure_tree_options[:dependent]
40
+
41
+ has_and_belongs_to_many :self_and_ancestors,
42
+ :class_name => base_class.to_s,
43
+ :join_table => hierarchy_table_name,
44
+ :foreign_key => "descendant_id",
45
+ :association_foreign_key => "ancestor_id",
46
+ :order => "generations asc"
47
+
48
+ has_and_belongs_to_many :self_and_descendants,
49
+ :class_name => base_class.to_s,
50
+ :join_table => hierarchy_table_name,
51
+ :foreign_key => "ancestor_id",
52
+ :association_foreign_key => "descendant_id",
53
+ :order => "generations asc"
49
54
 
50
55
  scope :roots, where(parent_column_name => nil)
51
56
 
52
- scope :leaves, includes(:descendants).where("#{hierarchy_table_name}.descendant_id is null")
57
+ scope :leaves, where(" #{quoted_table_name}.#{primary_key} IN
58
+ (SELECT ancestor_id
59
+ FROM #{quoted_hierarchy_table_name}
60
+ GROUP BY 1
61
+ HAVING MAX(generations) = 0)")
53
62
  end
54
63
  end
55
64
 
56
65
  module Model
57
66
  extend ActiveSupport::Concern
58
67
  module InstanceMethods
59
- def parent_id
60
- self[parent_column_name]
61
- end
62
-
63
- def parent_id= new_parent_id
64
- self[parent_column_name] = new_parent_id
65
- end
66
68
 
67
69
  # Returns true if this node has no parents.
68
70
  def root?
69
- parent_id.nil?
71
+ parent.nil?
70
72
  end
71
73
 
72
- # Returns self if +root?+ or the root ancestor
73
- def root
74
- root? ? self : ancestors.last
74
+ # Returns true if this node has a parent, and is not a root.
75
+ def child?
76
+ !parent.nil?
75
77
  end
76
78
 
77
79
  # Returns true if this node has no children.
@@ -79,81 +81,126 @@ module ClosureTree #:nodoc:
79
81
  children.empty?
80
82
  end
81
83
 
82
- def leaves
83
- return [self] if leaf?
84
- Tag.leaves.includes(:ancestors).where("ancestors_tags.id = ?", self.id)
84
+ # Returns the farthest ancestor, or self if +root?+
85
+ def root
86
+ root? ? self : ancestors.last
85
87
  end
86
88
 
87
- # Returns true if this node has a parent, and is not a root.
88
- def child?
89
- !parent_id.nil?
89
+ def leaves
90
+ return [self] if leaf?
91
+ self.class.leaves.where(<<-SQL
92
+ #{quoted_table_name}.#{self.class.primary_key} IN (
93
+ SELECT descendant_id
94
+ FROM #{quoted_hierarchy_table_name}
95
+ WHERE ancestor_id = #{id})
96
+ SQL
97
+ )
90
98
  end
91
99
 
92
100
  def level
93
101
  ancestors.size
94
102
  end
95
103
 
96
- def self_and_ancestors
97
- [self].concat ancestors.to_a
104
+ def ancestors
105
+ without_self(self_and_ancestors)
98
106
  end
99
107
 
100
108
  # Returns an array, root first, of self_and_ancestors' values of the +to_s_column+, which defaults
101
109
  # to the +name_column+.
102
110
  # (so child.ancestry_path == +%w{grandparent parent child}+
103
- def ancestry_path to_s_column = name_column
111
+ def ancestry_path(to_s_column = name_column)
104
112
  self_and_ancestors.reverse.collect { |n| n.send to_s_column.to_sym }
105
113
  end
106
114
 
107
- def self_and_descendants
108
- [self].concat descendants.to_a
115
+ def descendants
116
+ without_self(self_and_descendants)
109
117
  end
110
118
 
111
119
  def self_and_siblings
112
- self.class.scoped.where(:parent_id => parent_id)
120
+ self.class.scoped.where(:parent => parent)
113
121
  end
114
122
 
115
123
  def siblings
116
124
  without_self(self_and_siblings)
117
125
  end
118
126
 
119
- # You must use this method, or add child nodes to the +children+ association, to
120
- # make the hierarchy table stay consistent.
121
- def add_child child_node
122
- child_node.update_attribute :parent_id, self.id
123
- self_and_ancestors.inject(1) do |gen, ancestor|
124
- hierarchy_class.create!(:ancestor => ancestor, :descendant => child_node, :generations => gen)
125
- gen + 1
126
- end
127
- nil
128
- end
129
-
130
- def move_to_child_of new_parent
131
- connection.execute <<-SQL
132
- DELETE FROM #{quoted_hierarchy_table_name}
133
- WHERE descendant_id = #{child_node.id}
134
- SQL
135
- new_parent.add_child self
127
+ # alias for appending to the children collect
128
+ def add_child(child_node)
129
+ children << child_node
130
+ child_node
136
131
  end
137
132
 
138
133
  # Find a child node whose +ancestry_path+ minus self.ancestry_path is +path+.
139
134
  # If the first argument is a symbol, it will be used as the column to search by
140
- def find_by_path *path
141
- _find_or_create_by_path "find", path
135
+ def find_by_path(*path)
136
+ foc_by_path("find", *path)
142
137
  end
143
138
 
144
139
  # Find a child node whose +ancestry_path+ minus self.ancestry_path is +path+
145
- def find_or_create_by_path *path
146
- _find_or_create_by_path "find_or_create", path
140
+ def find_or_create_by_path(*path)
141
+ foc_by_path("find_or_create", *path)
147
142
  end
148
143
 
149
144
  protected
150
145
 
151
- def _find_or_create_by_path method_prefix, path
152
- to_s_column = path.first.is_a?(Symbol) ? path.shift.to_s : name_column
153
- path.flatten!
146
+ def acts_as_tree_before_save
147
+ @was_new_record = new_record?
148
+ if changes[parent_column_name] &&
149
+ parent.present? &&
150
+ parent.self_and_ancestors.include?(self)
151
+ # TODO: raise Ouroboros or Philip J. Fry error:
152
+ raise ActiveRecord::ActiveRecordError "You cannot add an ancestor as a descendant"
153
+ end
154
+ end
155
+
156
+ def acts_as_tree_after_save
157
+ rebuild! if changes[parent_column_name] || @was_new_record
158
+ end
159
+
160
+ def rebuild!
161
+ delete_hierarchy_references unless @was_new_record
162
+ hierarchy_class.create!(:ancestor => self, :descendant => self, :generations => 0)
163
+ unless root?
164
+ connection.execute <<-SQL
165
+ INSERT INTO #{quoted_hierarchy_table_name}
166
+ (ancestor_id, descendant_id, generations)
167
+ SELECT x.ancestor_id, #{id}, x.generations + 1
168
+ FROM #{quoted_hierarchy_table_name} x
169
+ WHERE x.descendant_id = #{self._parent_id}
170
+ SQL
171
+ end
172
+ children.each { |c| c.rebuild! }
173
+ end
174
+
175
+ def acts_as_tree_before_destroy
176
+ delete_hierarchy_references
177
+ if closure_tree_options[:dependent] == :nullify
178
+ children.each { |c| c.rebuild! }
179
+ end
180
+ end
181
+
182
+ def delete_hierarchy_references
183
+ # The crazy double-wrapped sub-subselect works around MySQL's limitation of subselects on the same table that is being mutated.
184
+ # It shouldn't affect performance of postgresql.
185
+ # See http://dev.mysql.com/doc/refman/5.0/en/subquery-errors.html
186
+ connection.execute <<-SQL
187
+ DELETE FROM #{quoted_hierarchy_table_name}
188
+ WHERE descendant_id IN (
189
+ SELECT DISTINCT descendant_id
190
+ FROM ( SELECT descendant_id
191
+ FROM #{quoted_hierarchy_table_name}
192
+ WHERE ancestor_id = #{id}
193
+ ) AS x )
194
+ OR descendant_id = #{id}
195
+ SQL
196
+ end
197
+
198
+ def foc_by_path(method_prefix, *path)
199
+ path = path.flatten
200
+ return self if path.empty?
154
201
  node = self
155
- while (s = path.shift and node)
156
- node = node.children.send("#{method_prefix}_by_#{to_s_column}".to_sym, s)
202
+ while (!path.empty? && node)
203
+ node = node.children.send("#{method_prefix}_by_#{name_column}", path.shift)
157
204
  end
158
205
  node
159
206
  end
@@ -162,9 +209,13 @@ module ClosureTree #:nodoc:
162
209
  scope.where(["#{quoted_table_name}.#{self.class.primary_key} != ?", self])
163
210
  end
164
211
 
212
+ def _parent_id
213
+ send(parent_column_name)
214
+ end
165
215
  end
166
216
 
167
217
  module ClassMethods
218
+
168
219
  # Returns an arbitrary node that has no parents.
169
220
  def root
170
221
  roots.first
@@ -173,42 +224,39 @@ module ClosureTree #:nodoc:
173
224
  # Rebuilds the hierarchy table based on the parent_id column in the database.
174
225
  # Note that the hierarchy table will be truncated.
175
226
  def rebuild!
176
- connection.execute <<-SQL
177
- DELETE FROM #{quoted_hierarchy_table_name}
178
- SQL
179
- roots.each { |n| rebuild_node_and_children n }
227
+ hierarchy_class.delete_all # not destroy_all -- we just want a simple truncate.
228
+ roots.each { |n| n.send(:rebuild!) } # roots just uses the parent_id column, so this is safe.
180
229
  nil
181
230
  end
182
231
 
183
232
  # Find the node whose +ancestry_path+ is +path+
184
- # If the first argument is a symbol, it will be used as the column to search by
185
- def find_by_path *path
186
- to_s_column = path.first.is_a?(Symbol) ? path.shift.to_s : name_column
187
- path.flatten!
188
- self.where(to_s_column => path.last).each do |n|
189
- return n if path == n.ancestry_path(to_s_column)
190
- end
191
- nil
233
+ def find_by_path(*path)
234
+ path = path.flatten
235
+ r = roots.send("find_by_#{name_column}", path.shift)
236
+ r.nil? ? nil : r.find_by_path(*path)
192
237
  end
193
238
 
194
239
  # Find or create nodes such that the +ancestry_path+ is +path+
195
- def find_or_create_by_path *path
196
- # short-circuit if we can:
197
- n = find_by_path path
198
- return n if n
199
-
200
- column_sym = path.first.is_a?(Symbol) ? path.shift : name_sym
201
- path.flatten!
202
- s = path.shift
203
- node = roots.where(column_sym => s).first
204
- node = create!(column_sym => s) unless node
205
- node.find_or_create_by_path column_sym, path
240
+ def find_or_create_by_path(*path)
241
+ path = path.flatten
242
+ root = roots.send("find_or_create_by_#{name_column}", path.shift)
243
+ root.find_or_create_by_path(*path)
206
244
  end
207
245
 
208
- private
209
- def rebuild_node_and_children node
210
- node.parent.add_child node if node.parent
211
- node.children.each { |child| rebuild_node_and_children child }
246
+ # From https://github.com/collectiveidea/awesome_nested_set:
247
+ def in_tenacious_transaction(&block)
248
+ retry_count = 0
249
+ begin
250
+ transaction(&block)
251
+ rescue ActiveRecord::StatementInvalid => error
252
+ raise unless connection.open_transactions.zero?
253
+ raise unless error.message =~ /Deadlock found when trying to get lock|Lock wait timeout exceeded/
254
+ raise unless retry_count < 10
255
+ retry_count += 1
256
+ logger.info "Deadlock detected on retry #{retry_count}, restarting transaction"
257
+ sleep(rand(retry_count)*0.2) # Aloha protocol
258
+ retry
259
+ end
212
260
  end
213
261
  end
214
262
  end
@@ -220,6 +268,10 @@ module ClosureTree #:nodoc:
220
268
  closure_tree_options[:parent_column_name]
221
269
  end
222
270
 
271
+ def parent_column_sym
272
+ parent_column_name.to_sym
273
+ end
274
+
223
275
  def has_name?
224
276
  ct_class.new.attributes.include? closure_tree_options[:name_column]
225
277
  end
@@ -233,7 +285,8 @@ module ClosureTree #:nodoc:
233
285
  end
234
286
 
235
287
  def hierarchy_table_name
236
- ct_table_name + closure_tree_options[:hierarchy_table_suffix]
288
+ # We need to use the table_name, not ct_class.to_s.demodulize, because they may have overridden the table name
289
+ closure_tree_options[:hierarchy_table_name] || ct_table_name.singularize + "_hierarchies"
237
290
  end
238
291
 
239
292
  def hierarchy_class_name
@@ -244,10 +297,6 @@ module ClosureTree #:nodoc:
244
297
  connection.quote_column_name hierarchy_table_name
245
298
  end
246
299
 
247
- def scope_column_names
248
- Array closure_tree_options[:scope]
249
- end
250
-
251
300
  def quoted_parent_column_name
252
301
  connection.quote_column_name parent_column_name
253
302
  end
@@ -263,6 +312,5 @@ module ClosureTree #:nodoc:
263
312
  def quoted_table_name
264
313
  connection.quote_column_name ct_table_name
265
314
  end
266
-
267
315
  end
268
316
  end