closure_tree 1.0.0 → 2.0.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
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