closure_tree 4.0.1 → 4.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -23,12 +23,14 @@ closure_tree has some great features:
23
23
  * __Best-in-class mutation performance__:
24
24
  * 2 SQL INSERTs on node creation
25
25
  * 3 SQL INSERT/UPDATEs on node reparenting
26
+ * __Support for Rails 3.0, 3.1, 3.2, and 4.0.0.rc1__
26
27
  * Support for reparenting children (and all their progeny)
27
28
  * Support for [concurrency](#concurrency) (using [with_advisory_lock](https://github/mceachen/with_advisory_lock))
28
29
  * Support for polymorphism [STI](#sti) within the hierarchy
29
30
  * ```find_or_create_by_path``` for [building out hierarchies quickly and conveniently](#find_or_create_by_path)
30
31
  * Support for [deterministic ordering](#deterministic-ordering) of children
31
32
  * Support for [preordered](http://en.wikipedia.org/wiki/Tree_traversal#Pre-order) traversal of descendants
33
+ * Support for rendering trees in [DOT format](http://en.wikipedia.org/wiki/DOT_(graph_description_language)), using [Graphviz](http://www.graphviz.org/)
32
34
  * Excellent [test coverage](#testing) in a variety of environments
33
35
 
34
36
  See [Bill Karwin](http://karwin.blogspot.com/)'s excellent
@@ -210,7 +212,25 @@ server may not be happy trying to do this.
210
212
 
211
213
  HT: [ancestry](https://github.com/stefankroes/ancestry#arrangement) and [elhoyos](https://github.com/mceachen/closure_tree/issues/11)
212
214
 
213
- ### <a id="options"></a>Available options
215
+ ### Graph visualization
216
+
217
+ ```to_dot_digraph``` is suitable for passing into [Graphviz](http://www.graphviz.org/).
218
+
219
+ For example, for the above tree, write out the DOT file with ruby:
220
+ ```ruby
221
+ File.open("example.dot", "w") { |f| f.write(Tag.root.to_dot_digraph) }
222
+ ```
223
+ Then, in a shell, ```dot -Tpng example.dot > example.png```, which produces:
224
+
225
+ ![Example tree](https://raw.github.com/mceachen/closure_tree/master/img/example.png)
226
+
227
+ If you want to customize the label value, override the ```#to_digraph_label``` instance method in your model.
228
+
229
+ Just for kicks, this is the test tree I used for proving that preordered tree traversal was correct:
230
+
231
+ ![Preordered test tree](https://raw.github.com/mceachen/closure_tree/master/img/preorder.png)
232
+
233
+ ### Available options
214
234
 
215
235
  When you include ```acts_as_tree``` in your model, you can provide a hash to override the following defaults:
216
236
 
@@ -433,10 +453,10 @@ rather than using fixtures? [Lots of people have written about this already](htt
433
453
 
434
454
  ## Testing
435
455
 
436
- Closure tree is [tested under every combination](http://travis-ci.org/#!/mceachen/closure_tree) of
456
+ Closure tree is [tested under every valid combination](http://travis-ci.org/#!/mceachen/closure_tree) of
437
457
 
438
- * Ruby 1.8.7 and Ruby 1.9.3
439
- * The latest Rails 3.0, 3.1, and 3.2 branches, and
458
+ * Ruby 1.8.7, Ruby 1.9.3, and Ruby 2.0.0
459
+ * The latest Rails 3.0, 3.1, 3.2, and 4.0 branches, and
440
460
  * MySQL and PostgreSQL. SQLite works in a single-threaded environment.
441
461
 
442
462
  Assuming you're using [rbenv](https://github.com/sstephenson/rbenv), you can use ```tests.sh``` to
@@ -445,8 +465,14 @@ run the test matrix locally.
445
465
  Parallelism is not tested with Rails 3.0.x nor 3.1.x due to this
446
466
  [known issue](https://github.com/rails/rails/issues/7538).
447
467
 
468
+
448
469
  ## Change log
449
470
 
471
+ ### 4.1.0
472
+
473
+ * Added support for Rails 4.0.0.rc1 and Ruby 2.0.0 (while maintaining backward compatibility with Rails 3, BOOYA)
474
+ * Added ```#to_dot_digraph```, suitable for Graphviz rendering
475
+
450
476
  ### 4.0.1
451
477
 
452
478
  * Numeric, deterministically ordered siblings will always be [0..#{self_and_siblings.count}]
data/Rakefile CHANGED
@@ -16,10 +16,14 @@ RSpec::Core::RakeTask.new(:spec)
16
16
 
17
17
  task :default => :spec
18
18
 
19
- task :specs_with_db_ixes do
19
+ task :all_spec_flavors do
20
20
  [["", ""], ["db_prefix_", ""], ["", "_db_suffix"], ["abc_", "_123"]].each do |prefix, suffix|
21
21
  fail unless system("rake spec DB_PREFIX=#{prefix} DB_SUFFIX=#{suffix}")
22
22
  end
23
+ require 'active_record/version'
24
+ if ActiveRecord::VERSION::MAJOR == 3
25
+ system("rake spec ATTR_ACCESSIBLE=1")
26
+ end
23
27
  end
24
28
 
25
29
  # Run the specs using all the different database engines:
@@ -18,7 +18,7 @@ module ClosureTree
18
18
  include ClosureTree::Model
19
19
  include ClosureTree::WithAdvisoryLock
20
20
 
21
- if _ct.order_option
21
+ if _ct.order_option?
22
22
  include ClosureTree::DeterministicOrdering
23
23
  include ClosureTree::DeterministicNumericOrdering if _ct.order_is_numeric?
24
24
  end
@@ -5,50 +5,50 @@ module ClosureTree
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  included do
8
- validate :ct_validate
9
- before_save :ct_before_save
10
- after_save :ct_after_save
11
- before_destroy :ct_before_destroy
8
+ validate :_ct_validate
9
+ before_save :_ct_before_save
10
+ after_save :_ct_after_save
11
+ before_destroy :_ct_before_destroy
12
12
 
13
13
  belongs_to :parent,
14
14
  :class_name => _ct.model_class.to_s,
15
15
  :foreign_key => _ct.parent_column_name
16
16
 
17
- unless defined?(ActiveModel::ForbiddenAttributesProtection) && ancestors.include?(ActiveModel::ForbiddenAttributesProtection)
18
- attr_accessible :parent
19
- end
17
+ attr_accessible :parent if _ct.use_attr_accessible?
18
+
19
+ order_by_generations = "#{_ct.quoted_hierarchy_table_name}.generations asc"
20
20
 
21
- has_many :children, _ct.with_order_option(
21
+ has_many :children, *_ct.has_many_with_order_option(
22
22
  :class_name => _ct.model_class.to_s,
23
23
  :foreign_key => _ct.parent_column_name,
24
24
  :dependent => _ct.options[:dependent])
25
25
 
26
- has_many :ancestor_hierarchies,
26
+ has_many :ancestor_hierarchies, *_ct.has_many_without_order_option(
27
27
  :class_name => _ct.hierarchy_class_name,
28
28
  :foreign_key => "descendant_id",
29
- :order => "#{_ct.quoted_hierarchy_table_name}.generations asc"
29
+ :order => order_by_generations)
30
30
 
31
- has_many :self_and_ancestors,
31
+ has_many :self_and_ancestors, *_ct.has_many_without_order_option(
32
32
  :through => :ancestor_hierarchies,
33
33
  :source => :ancestor,
34
- :order => "#{_ct.quoted_hierarchy_table_name}.generations asc"
34
+ :order => order_by_generations)
35
35
 
36
- has_many :descendant_hierarchies,
36
+ has_many :descendant_hierarchies, *_ct.has_many_without_order_option(
37
37
  :class_name => _ct.hierarchy_class_name,
38
38
  :foreign_key => "ancestor_id",
39
- :order => "#{_ct.quoted_hierarchy_table_name}.generations asc"
39
+ :order => order_by_generations)
40
40
 
41
41
  # TODO: FIXME: this collection currently ignores sort_order
42
42
  # (because the quoted_table_named would need to be joined in to get to the order column)
43
43
 
44
- has_many :self_and_descendants, _ct.with_order_option(
44
+ has_many :self_and_descendants, *_ct.has_many_with_order_option(
45
45
  :through => :descendant_hierarchies,
46
46
  :source => :descendant,
47
- :order => "#{_ct.quoted_hierarchy_table_name}.generations asc")
47
+ :order => order_by_generations)
48
48
 
49
49
  scope :without, lambda { |instance|
50
50
  if instance.new_record?
51
- scoped
51
+ all
52
52
  else
53
53
  where(["#{_ct.quoted_table_name}.#{_ct.base_class.primary_key} != ?", instance.id])
54
54
  end
@@ -62,7 +62,7 @@ module ClosureTree
62
62
 
63
63
  # Returns true if this node has no parents.
64
64
  def root?
65
- parent_id.nil?
65
+ _ct_parent_id.nil?
66
66
  end
67
67
 
68
68
  # Returns true if this node has a parent, and is not a root.
@@ -118,7 +118,7 @@ module ClosureTree
118
118
  end
119
119
 
120
120
  def self_and_siblings
121
- _ct.scope_with_order(_ct.base_class.where(_ct.parent_column_sym => parent_id))
121
+ _ct.scope_with_order(_ct.base_class.where(_ct.parent_column_sym => _ct_parent_id))
122
122
  end
123
123
 
124
124
  def siblings
@@ -190,11 +190,20 @@ module ClosureTree
190
190
  self.class.build_hash_tree(hash_tree_scope(options[:limit_depth]))
191
191
  end
192
192
 
193
- def parent_id
193
+ # override this method in your model class if you want a different digraph label.
194
+ def to_digraph_label
195
+ _ct.has_name? ? read_attribute(_ct.name_column) : to_s
196
+ end
197
+
198
+ def _ct_parent_id
194
199
  read_attribute(_ct.parent_column_sym)
195
200
  end
196
201
 
197
- def ct_validate
202
+ def _ct_id
203
+ read_attribute(_ct.model_class.primary_key)
204
+ end
205
+
206
+ def _ct_validate
198
207
  if changes[_ct.parent_column_name] &&
199
208
  parent.present? &&
200
209
  parent.self_and_ancestors.include?(self)
@@ -202,12 +211,12 @@ module ClosureTree
202
211
  end
203
212
  end
204
213
 
205
- def ct_before_save
214
+ def _ct_before_save
206
215
  @was_new_record = new_record?
207
216
  true # don't cancel the save
208
217
  end
209
218
 
210
- def ct_after_save
219
+ def _ct_after_save
211
220
  rebuild! if changes[_ct.parent_column_name] || @was_new_record
212
221
  @was_new_record = false # we aren't new anymore.
213
222
  true # don't cancel anything.
@@ -221,17 +230,17 @@ module ClosureTree
221
230
  sql = <<-SQL
222
231
  INSERT INTO #{_ct.quoted_hierarchy_table_name}
223
232
  (ancestor_id, descendant_id, generations)
224
- SELECT x.ancestor_id, #{_ct.quote(id)}, x.generations + 1
233
+ SELECT x.ancestor_id, #{_ct.quote(_ct_id)}, x.generations + 1
225
234
  FROM #{_ct.quoted_hierarchy_table_name} x
226
- WHERE x.descendant_id = #{_ct.quote(self.parent_id)}
235
+ WHERE x.descendant_id = #{_ct.quote(_ct_parent_id)}
227
236
  SQL
228
- connection.execute sql.strip
237
+ _ct.connection.execute sql.strip
229
238
  end
230
239
  children.each { |c| c.rebuild! }
231
240
  end
232
241
  end
233
242
 
234
- def ct_before_destroy
243
+ def _ct_before_destroy
235
244
  delete_hierarchy_references
236
245
  if _ct.options[:dependent] == :nullify
237
246
  children.each { |c| c.rebuild! }
@@ -243,7 +252,7 @@ module ClosureTree
243
252
  # It shouldn't affect performance of postgresql.
244
253
  # See http://dev.mysql.com/doc/refman/5.0/en/subquery-errors.html
245
254
  # Also: PostgreSQL doesn't support INNER JOIN on DELETE, so we can't use that.
246
- connection.execute <<-SQL
255
+ _ct.connection.execute <<-SQL
247
256
  DELETE FROM #{_ct.quoted_hierarchy_table_name}
248
257
  WHERE descendant_id IN (
249
258
  SELECT DISTINCT descendant_id
@@ -259,6 +268,10 @@ module ClosureTree
259
268
  scope.without(self)
260
269
  end
261
270
 
271
+ def to_dot_digraph
272
+ self.class.to_dot_digraph(self_and_descendants)
273
+ end
274
+
262
275
  module ClassMethods
263
276
  def roots
264
277
  _ct.scope_with_order(where(_ct.parent_column_name => nil))
@@ -284,7 +297,7 @@ module ClosureTree
284
297
  HAVING MAX(#{_ct.quoted_hierarchy_table_name}.generations) = 0
285
298
  ) AS leaves ON (#{_ct.quoted_table_name}.#{primary_key} = leaves.ancestor_id)
286
299
  SQL
287
- _ct.scope_with_order(s)
300
+ _ct.scope_with_order(s.readonly(false))
288
301
  end
289
302
 
290
303
  # Rebuilds the hierarchy table based on the parent_id column in the database.
@@ -325,11 +338,12 @@ module ClosureTree
325
338
 
326
339
  def ct_scoped_to_path(path, parent_constraint)
327
340
  path = path.is_a?(Enumerable) ? path.dup : [path]
328
- scope = scoped.where(_ct.name_sym => path.last).readonly(false)
341
+ scope = where(_ct.name_sym => path.last).readonly(false)
329
342
  path[0..-2].reverse.each_with_index do |ea, idx|
330
343
  subtable = idx == 0 ? _ct.quoted_table_name : "p#{idx - 1}"
331
344
  scope = scope.joins(<<-SQL)
332
- INNER JOIN #{_ct.quoted_table_name} AS p#{idx} ON p#{idx}.id = #{subtable}.#{_ct.parent_column_name}
345
+ INNER JOIN #{_ct.quoted_table_name} AS p#{idx}
346
+ ON p#{idx}.#{_ct.quoted_id_column_name} = #{subtable}.#{_ct.parent_column_name}
333
347
  SQL
334
348
  scope = scope.where("p#{idx}.#{_ct.quoted_name_column} = #{_ct.quote(ea)}")
335
349
  end
@@ -355,12 +369,13 @@ module ClosureTree
355
369
  def hash_tree_scope(limit_depth = nil)
356
370
  # Deepest generation, within limit, for each descendant
357
371
  # NOTE: Postgres requires HAVING clauses to always contains aggregate functions (!!)
372
+ having_clause = limit_depth ? "HAVING MAX(generations) <= #{limit_depth - 1}" : ''
358
373
  generation_depth = <<-SQL
359
374
  INNER JOIN (
360
375
  SELECT descendant_id, MAX(generations) as depth
361
376
  FROM #{_ct.quoted_hierarchy_table_name}
362
377
  GROUP BY descendant_id
363
- #{limit_depth ? "HAVING MAX(generations) <= #{limit_depth - 1}" : ""}
378
+ #{having_clause}
364
379
  ) AS generation_depth
365
380
  ON #{_ct.quoted_table_name}.#{primary_key} = generation_depth.descendant_id
366
381
  SQL
@@ -377,11 +392,26 @@ module ClosureTree
377
392
  if ea.root? || tree.empty? # We're at the top of the tree.
378
393
  tree[ea] = h
379
394
  else
380
- id_to_hash[ea.parent_id][ea] = h
395
+ id_to_hash[ea._ct_parent_id][ea] = h
381
396
  end
382
397
  end
383
398
  tree
384
399
  end
400
+
401
+ # Renders the given scope as a DOT digraph, suitable for rendering by Graphviz
402
+ def to_dot_digraph(tree_scope)
403
+ id_to_instance = tree_scope.inject({}) { |h, ea| h[ea.id] = ea; h }
404
+ output = StringIO.new
405
+ output << "digraph G {\n"
406
+ tree_scope.each do |ea|
407
+ if id_to_instance.has_key? ea._ct_parent_id
408
+ output << " #{ea._ct_parent_id} -> #{ea._ct_id}\n"
409
+ end
410
+ output << " #{ea._ct_id} [label=\"#{ea.to_digraph_label}\"]\n"
411
+ end
412
+ output << "}\n"
413
+ output.string
414
+ end
385
415
  end
386
416
  end
387
417
  end
@@ -5,7 +5,7 @@ module ClosureTree
5
5
 
6
6
  def self_and_descendants_preordered
7
7
  # TODO: raise NotImplementedError if sort_order is not numeric and not null?
8
- h = connection.select_one(<<-SQL)
8
+ h = _ct.connection.select_one(<<-SQL)
9
9
  SELECT
10
10
  count(*) as total_descendants,
11
11
  max(generations) as max_depth
@@ -28,7 +28,7 @@ module ClosureTree
28
28
 
29
29
  module ClassMethods
30
30
  def roots_and_descendants_preordered
31
- h = connection.select_one(<<-SQL)
31
+ h = _ct.connection.select_one(<<-SQL)
32
32
  SELECT
33
33
  count(*) as total_descendants,
34
34
  max(generations) as max_depth
@@ -20,12 +20,27 @@ module ClosureTree
20
20
  model_class.connection
21
21
  end
22
22
 
23
+ def use_attr_accessible?
24
+ ActiveRecord::VERSION::MAJOR == 3 &&
25
+ defined?(ActiveModel::MassAssignmentSecurity) &&
26
+ model_class.ancestors.include?(ActiveModel::MassAssignmentSecurity)
27
+ end
28
+
29
+ def include_forbidden_attributes_protection?
30
+ ActiveRecord::VERSION::MAJOR == 3 &&
31
+ defined?(ActiveModel::ForbiddenAttributesProtection) &&
32
+ model_class.ancestors.include?(ActiveModel::ForbiddenAttributesProtection)
33
+ end
34
+
23
35
  def hierarchy_class_for_model
24
36
  hierarchy_class = model_class.parent.const_set(short_hierarchy_class_name, Class.new(ActiveRecord::Base))
37
+ use_attr_accessible = use_attr_accessible?
38
+ include_forbidden_attributes_protection = include_forbidden_attributes_protection?
25
39
  hierarchy_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
40
+ include ActiveModel::ForbiddenAttributesProtection if include_forbidden_attributes_protection
26
41
  belongs_to :ancestor, :class_name => "#{model_class}"
27
42
  belongs_to :descendant, :class_name => "#{model_class}"
28
- attr_accessible :ancestor, :descendant, :generations
43
+ attr_accessible :ancestor, :descendant, :generations if use_attr_accessible
29
44
  def ==(other)
30
45
  self.class == other.class && ancestor_id == other.ancestor_id && descendant_id == other.descendant_id
31
46
  end
@@ -87,6 +102,10 @@ module ClosureTree
87
102
  connection.quote_table_name hierarchy_table_name
88
103
  end
89
104
 
105
+ def quoted_id_column_name
106
+ connection.quote_column_name model_class.primary_key
107
+ end
108
+
90
109
  def quoted_parent_column_name
91
110
  connection.quote_column_name parent_column_name
92
111
  end
@@ -103,38 +122,55 @@ module ClosureTree
103
122
  !options[:order].nil?
104
123
  end
105
124
 
106
- def order_option
107
- options[:order].to_s
125
+ def with_order_option(opts)
126
+ if order_option?
127
+ opts[:order] = [opts[:order], options[:order]].compact.join(",")
128
+ end
129
+ opts
108
130
  end
109
131
 
110
- def with_order_option(options)
111
- order_option? ? options.merge(:order => order_option) : options
132
+ def scope_with_order(scope, additional_order_by = nil)
133
+ order_option? ? scope.order(*([additional_order_by, options[:order]].compact)) : scope
112
134
  end
113
135
 
114
- def scope_with_order(scope, additional_order_by = nil)
115
- order_option? ? scope.order(*([additional_order_by, order_option].compact)) : scope
136
+ # lambda-ize the order, but don't apply the default order_option
137
+ def has_many_without_order_option(opts)
138
+ if ActiveRecord::VERSION::MAJOR > 3
139
+ [lambda { order(opts[:order]) }, opts.except(:order)]
140
+ else
141
+ [opts]
142
+ end
116
143
  end
117
144
 
118
- def with_order_option(options)
119
- if order_option?
120
- options[:order] = [options[:order], order_option].compact.join(",")
145
+ def has_many_with_order_option(opts)
146
+ if ActiveRecord::VERSION::MAJOR > 3
147
+ order_options = [opts[:order], options[:order]].compact
148
+ [lambda { order(order_options) }, opts.except(:order)]
149
+ else
150
+ [with_order_option(opts)]
121
151
  end
122
- options
123
152
  end
124
153
 
125
154
  def order_is_numeric?
126
155
  # The table might not exist yet (in the case of ActiveRecord::Observer use, see issue 32)
127
156
  return false if !order_option? || !model_class.table_exists?
128
- c = model_class.columns_hash[order_option]
157
+ c = model_class.columns_hash[order_column]
129
158
  c && c.type == :integer
130
159
  end
131
160
 
132
161
  def order_column
133
- order_option.split(' ', 2).first if order_option?
162
+ o = options[:order]
163
+ if o.nil?
164
+ nil
165
+ elsif o.is_a?(String)
166
+ o.split(' ', 2).first
167
+ else
168
+ o.to_s
169
+ end
134
170
  end
135
171
 
136
172
  def require_order_column
137
- raise ":order value, '#{order_option}', isn't a column" if order_column.nil?
173
+ raise ":order value, '#{options[:order]}', isn't a column" if order_column.nil?
138
174
  end
139
175
 
140
176
  def order_column_sym
@@ -181,9 +217,9 @@ module ClosureTree
181
217
 
182
218
  def ids_from(scope)
183
219
  if scope.respond_to? :pluck
184
- scope.pluck(:id)
220
+ scope.pluck(model_class.primary_key)
185
221
  else
186
- scope.select(:id).collect(&:id)
222
+ scope.select(model_class.primary_key).map { |ea| ea._ct_id }
187
223
  end
188
224
  end
189
225
  end