closure_tree 4.0.1 → 4.1.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.
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