closure_tree 6.5.0 → 7.4.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.
Files changed (63) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/ci.yml +98 -0
  3. data/.gitignore +2 -0
  4. data/.rspec +1 -1
  5. data/Appraisals +90 -7
  6. data/CHANGELOG.md +100 -42
  7. data/Gemfile +3 -11
  8. data/README.md +68 -24
  9. data/Rakefile +16 -10
  10. data/_config.yml +1 -0
  11. data/bin/appraisal +29 -0
  12. data/bin/rake +29 -0
  13. data/bin/rspec +29 -0
  14. data/closure_tree.gemspec +16 -9
  15. data/lib/closure_tree/finders.rb +32 -9
  16. data/lib/closure_tree/has_closure_tree.rb +4 -0
  17. data/lib/closure_tree/has_closure_tree_root.rb +5 -7
  18. data/lib/closure_tree/hash_tree_support.rb +4 -4
  19. data/lib/closure_tree/hierarchy_maintenance.rb +28 -8
  20. data/lib/closure_tree/model.rb +42 -16
  21. data/lib/closure_tree/numeric_deterministic_ordering.rb +20 -6
  22. data/lib/closure_tree/numeric_order_support.rb +7 -3
  23. data/lib/closure_tree/support.rb +18 -12
  24. data/lib/closure_tree/support_attributes.rb +10 -1
  25. data/lib/closure_tree/support_flags.rb +1 -4
  26. data/lib/closure_tree/version.rb +1 -1
  27. data/lib/generators/closure_tree/migration_generator.rb +8 -0
  28. data/lib/generators/closure_tree/templates/create_hierarchies_table.rb.erb +1 -1
  29. metadata +78 -79
  30. data/.travis.yml +0 -29
  31. data/gemfiles/activerecord_4.2.gemfile +0 -19
  32. data/gemfiles/activerecord_5.0.gemfile +0 -19
  33. data/gemfiles/activerecord_5.0_foreigner.gemfile +0 -20
  34. data/gemfiles/activerecord_edge.gemfile +0 -20
  35. data/img/example.png +0 -0
  36. data/img/preorder.png +0 -0
  37. data/spec/cache_invalidation_spec.rb +0 -39
  38. data/spec/cuisine_type_spec.rb +0 -38
  39. data/spec/db/database.yml +0 -21
  40. data/spec/db/models.rb +0 -128
  41. data/spec/db/schema.rb +0 -166
  42. data/spec/fixtures/tags.yml +0 -98
  43. data/spec/generators/migration_generator_spec.rb +0 -48
  44. data/spec/has_closure_tree_root_spec.rb +0 -154
  45. data/spec/hierarchy_maintenance_spec.rb +0 -16
  46. data/spec/label_spec.rb +0 -554
  47. data/spec/matcher_spec.rb +0 -34
  48. data/spec/metal_spec.rb +0 -55
  49. data/spec/model_spec.rb +0 -9
  50. data/spec/namespace_type_spec.rb +0 -13
  51. data/spec/parallel_spec.rb +0 -159
  52. data/spec/spec_helper.rb +0 -24
  53. data/spec/support/database.rb +0 -52
  54. data/spec/support/database_cleaner.rb +0 -14
  55. data/spec/support/exceed_query_limit.rb +0 -18
  56. data/spec/support/hash_monkey_patch.rb +0 -13
  57. data/spec/support/query_counter.rb +0 -18
  58. data/spec/support/sqlite3_with_advisory_lock.rb +0 -10
  59. data/spec/support_spec.rb +0 -14
  60. data/spec/tag_examples.rb +0 -665
  61. data/spec/tag_spec.rb +0 -6
  62. data/spec/user_spec.rb +0 -174
  63. data/spec/uuid_tag_spec.rb +0 -6
@@ -20,7 +20,7 @@ module ClosureTree
20
20
  end
21
21
 
22
22
  def _ct_validate
23
- if !@_ct_skip_cycle_detection &&
23
+ if !(defined? @_ct_skip_cycle_detection) &&
24
24
  !new_record? && # don't validate for cycles if we're a new record
25
25
  changes[_ct.parent_column_name] && # don't validate for cycles if we didn't change our parent
26
26
  parent.present? && # don't validate if we're root
@@ -35,10 +35,13 @@ module ClosureTree
35
35
  end
36
36
 
37
37
  def _ct_after_save
38
- if changes[_ct.parent_column_name] || @was_new_record
38
+ as_5_1 = ActiveSupport.version >= Gem::Version.new('5.1.0')
39
+ changes_method = as_5_1 ? :saved_changes : :changes
40
+
41
+ if public_send(changes_method)[_ct.parent_column_name] || @was_new_record
39
42
  rebuild!
40
43
  end
41
- if changes[_ct.parent_column_name] && !@was_new_record
44
+ if public_send(changes_method)[_ct.parent_column_name] && !@was_new_record
42
45
  # Resetting the ancestral collections addresses
43
46
  # https://github.com/mceachen/closure_tree/issues/68
44
47
  ancestor_hierarchies.reload
@@ -61,10 +64,10 @@ module ClosureTree
61
64
 
62
65
  def rebuild!(called_by_rebuild = false)
63
66
  _ct.with_advisory_lock do
64
- delete_hierarchy_references unless @was_new_record
67
+ delete_hierarchy_references unless (defined? @was_new_record) && @was_new_record
65
68
  hierarchy_class.create!(:ancestor => self, :descendant => self, :generations => 0)
66
69
  unless root?
67
- _ct.connection.execute <<-SQL.strip_heredoc
70
+ _ct.connection.execute <<-SQL.squish
68
71
  INSERT INTO #{_ct.quoted_hierarchy_table_name}
69
72
  (ancestor_id, descendant_id, generations)
70
73
  SELECT x.ancestor_id, #{_ct.quote(_ct_id)}, x.generations + 1
@@ -91,7 +94,7 @@ module ClosureTree
91
94
  # It shouldn't affect performance of postgresql.
92
95
  # See http://dev.mysql.com/doc/refman/5.0/en/subquery-errors.html
93
96
  # Also: PostgreSQL doesn't support INNER JOIN on DELETE, so we can't use that.
94
- _ct.connection.execute <<-SQL.strip_heredoc
97
+ _ct.connection.execute <<-SQL.squish
95
98
  DELETE FROM #{_ct.quoted_hierarchy_table_name}
96
99
  WHERE descendant_id IN (
97
100
  SELECT DISTINCT descendant_id
@@ -99,7 +102,7 @@ module ClosureTree
99
102
  FROM #{_ct.quoted_hierarchy_table_name}
100
103
  WHERE ancestor_id = #{_ct.quote(id)}
101
104
  OR descendant_id = #{_ct.quote(id)}
102
- ) AS x )
105
+ ) #{ _ct.t_alias_keyword } x )
103
106
  SQL
104
107
  end
105
108
  end
@@ -109,11 +112,28 @@ module ClosureTree
109
112
  # Note that the hierarchy table will be truncated.
110
113
  def rebuild!
111
114
  _ct.with_advisory_lock do
112
- hierarchy_class.delete_all # not destroy_all -- we just want a simple truncate.
115
+ cleanup!
113
116
  roots.find_each { |n| n.send(:rebuild!) } # roots just uses the parent_id column, so this is safe.
114
117
  end
115
118
  nil
116
119
  end
120
+
121
+ def cleanup!
122
+ hierarchy_table = hierarchy_class.arel_table
123
+
124
+ [:descendant_id, :ancestor_id].each do |foreign_key|
125
+ alias_name = foreign_key.to_s.split('_').first + "s"
126
+ alias_table = Arel::Table.new(table_name).alias(alias_name)
127
+ arel_join = hierarchy_table.join(alias_table, Arel::Nodes::OuterJoin)
128
+ .on(alias_table[primary_key].eq(hierarchy_table[foreign_key]))
129
+ .join_sources
130
+
131
+ lonely_childs = hierarchy_class.joins(arel_join).where(alias_table[primary_key].eq(nil))
132
+ ids = lonely_childs.pluck(foreign_key)
133
+
134
+ hierarchy_class.where(hierarchy_table[foreign_key].in(ids)).delete_all
135
+ end
136
+ end
117
137
  end
118
138
  end
119
139
  end
@@ -6,20 +6,20 @@ module ClosureTree
6
6
 
7
7
  included do
8
8
 
9
- belongs_to :parent, nil, *_ct.belongs_to_with_optional_option(
9
+ belongs_to :parent, nil, **_ct.belongs_to_with_optional_option(
10
10
  class_name: _ct.model_class.to_s,
11
11
  foreign_key: _ct.parent_column_name,
12
12
  inverse_of: :children,
13
13
  touch: _ct.options[:touch],
14
14
  optional: true)
15
15
 
16
- order_by_generations = "#{_ct.quoted_hierarchy_table_name}.generations asc"
16
+ order_by_generations = -> { Arel.sql("#{_ct.quoted_hierarchy_table_name}.generations ASC") }
17
17
 
18
- has_many :children, *_ct.has_many_with_order_option(
18
+ has_many :children, *_ct.has_many_order_with_option, **{
19
19
  class_name: _ct.model_class.to_s,
20
20
  foreign_key: _ct.parent_column_name,
21
21
  dependent: _ct.options[:dependent],
22
- inverse_of: :parent) do
22
+ inverse_of: :parent } do
23
23
  # We have to redefine hash_tree because the activerecord relation is already scoped to parent_id.
24
24
  def hash_tree(options = {})
25
25
  # we want limit_depth + 1 because we don't do self_and_descendants.
@@ -28,25 +28,21 @@ module ClosureTree
28
28
  end
29
29
  end
30
30
 
31
- has_many :ancestor_hierarchies, *_ct.has_many_without_order_option(
31
+ has_many :ancestor_hierarchies, *_ct.has_many_order_without_option(order_by_generations),
32
32
  class_name: _ct.hierarchy_class_name,
33
- foreign_key: 'descendant_id',
34
- order: order_by_generations)
33
+ foreign_key: 'descendant_id'
35
34
 
36
- has_many :self_and_ancestors, *_ct.has_many_without_order_option(
35
+ has_many :self_and_ancestors, *_ct.has_many_order_without_option(order_by_generations),
37
36
  through: :ancestor_hierarchies,
38
- source: :ancestor,
39
- order: order_by_generations)
37
+ source: :ancestor
40
38
 
41
- has_many :descendant_hierarchies, *_ct.has_many_without_order_option(
39
+ has_many :descendant_hierarchies, *_ct.has_many_order_without_option(order_by_generations),
42
40
  class_name: _ct.hierarchy_class_name,
43
- foreign_key: 'ancestor_id',
44
- order: order_by_generations)
41
+ foreign_key: 'ancestor_id'
45
42
 
46
- has_many :self_and_descendants, *_ct.has_many_with_order_option(
43
+ has_many :self_and_descendants, *_ct.has_many_order_with_option(order_by_generations),
47
44
  through: :descendant_hierarchies,
48
- source: :descendant,
49
- order: order_by_generations)
45
+ source: :descendant
50
46
  end
51
47
 
52
48
  # Delegate to the Support instance on the class:
@@ -134,6 +130,36 @@ module ClosureTree
134
130
  _ct.ids_from(siblings)
135
131
  end
136
132
 
133
+ # node's parent is this record
134
+ def parent_of?(node)
135
+ self == node.parent
136
+ end
137
+
138
+ # node's root is this record
139
+ def root_of?(node)
140
+ self == node.root
141
+ end
142
+
143
+ # node's ancestors include this record
144
+ def ancestor_of?(node)
145
+ node.ancestors.include? self
146
+ end
147
+
148
+ # node is record's ancestor
149
+ def descendant_of?(node)
150
+ self.ancestors.include? node
151
+ end
152
+
153
+ # node is record's parent
154
+ def child_of?(node)
155
+ self.parent == node
156
+ end
157
+
158
+ # node and record have a same root
159
+ def family_of?(node)
160
+ self.root == node.root
161
+ end
162
+
137
163
  # Alias for appending to the children collection.
138
164
  # You can also add directly to the children collection, if you'd prefer.
139
165
  def add_child(child_node)
@@ -10,8 +10,13 @@ module ClosureTree
10
10
  end
11
11
 
12
12
  def _ct_reorder_prior_siblings_if_parent_changed
13
- if attribute_changed?(_ct.parent_column_name) && !@was_new_record
14
- was_parent_id = attribute_was(_ct.parent_column_name)
13
+ as_5_1 = ActiveSupport.version >= Gem::Version.new('5.1.0')
14
+ change_method = as_5_1 ? :saved_change_to_attribute? : :attribute_changed?
15
+
16
+ if public_send(change_method, _ct.parent_column_name) && !@was_new_record
17
+ attribute_method = as_5_1 ? :attribute_before_last_save : :attribute_was
18
+
19
+ was_parent_id = public_send(attribute_method, _ct.parent_column_name)
15
20
  _ct.reorder_with_parent_id(was_parent_id)
16
21
  end
17
22
  end
@@ -46,7 +51,7 @@ module ClosureTree
46
51
 
47
52
  # If node is nil, order the whole tree.
48
53
  def _ct_sum_order_by(node = nil)
49
- stats_sql = <<-SQL.strip_heredoc
54
+ stats_sql = <<-SQL.squish
50
55
  SELECT
51
56
  count(*) as total_descendants,
52
57
  max(generations) as max_depth
@@ -60,11 +65,16 @@ module ClosureTree
60
65
  node_score = "(1 + anc.#{_ct.quoted_order_column(false)}) * " +
61
66
  "power(#{h['total_descendants']}, #{h['max_depth'].to_i + 1} - #{depth_column})"
62
67
 
63
- "sum(#{node_score})"
68
+ # We want the NULLs to be first in case we are not ordering roots and they have NULL order.
69
+ Arel.sql("SUM(#{node_score}) IS NULL DESC, SUM(#{node_score})")
64
70
  end
65
71
 
66
72
  def roots_and_descendants_preordered
67
- join_sql = <<-SQL.strip_heredoc
73
+ if _ct.dont_order_roots
74
+ raise ClosureTree::RootOrderingDisabledError.new("Root ordering is disabled on this model")
75
+ end
76
+
77
+ join_sql = <<-SQL.squish
68
78
  JOIN #{_ct.quoted_hierarchy_table_name} anc_hier
69
79
  ON anc_hier.descendant_id = #{_ct.quoted_table_name}.#{_ct.quoted_id_column_name}
70
80
  JOIN #{_ct.quoted_table_name} anc
@@ -73,7 +83,7 @@ module ClosureTree
73
83
  SELECT descendant_id, max(generations) AS max_depth
74
84
  FROM #{_ct.quoted_hierarchy_table_name}
75
85
  GROUP BY descendant_id
76
- ) AS depths ON depths.descendant_id = anc.#{_ct.quoted_id_column_name}
86
+ ) #{ _ct.t_alias_keyword } depths ON depths.descendant_id = anc.#{_ct.quoted_id_column_name}
77
87
  SQL
78
88
  joins(join_sql)
79
89
  .group("#{_ct.quoted_table_name}.#{_ct.quoted_id_column_name}")
@@ -108,6 +118,10 @@ module ClosureTree
108
118
  def add_sibling(sibling, add_after = true)
109
119
  fail "can't add self as sibling" if self == sibling
110
120
 
121
+ if _ct.dont_order_roots && parent.nil?
122
+ raise ClosureTree::RootOrderingDisabledError.new("Root ordering is disabled on this model")
123
+ end
124
+
111
125
  # Make sure self isn't dirty, because we're going to call reload:
112
126
  save
113
127
 
@@ -14,13 +14,14 @@ module ClosureTree
14
14
 
15
15
  module MysqlAdapter
16
16
  def reorder_with_parent_id(parent_id, minimum_sort_order_value = nil)
17
+ return if parent_id.nil? && dont_order_roots
17
18
  min_where = if minimum_sort_order_value
18
19
  "AND #{quoted_order_column} >= #{minimum_sort_order_value}"
19
20
  else
20
21
  ""
21
22
  end
22
23
  connection.execute 'SET @i = 0'
23
- connection.execute <<-SQL.strip_heredoc
24
+ connection.execute <<-SQL.squish
24
25
  UPDATE #{quoted_table_name}
25
26
  SET #{quoted_order_column} = (@i := @i + 1) + #{minimum_sort_order_value.to_i - 1}
26
27
  WHERE #{where_eq(parent_column_name, parent_id)} #{min_where}
@@ -31,12 +32,13 @@ module ClosureTree
31
32
 
32
33
  module PostgreSQLAdapter
33
34
  def reorder_with_parent_id(parent_id, minimum_sort_order_value = nil)
35
+ return if parent_id.nil? && dont_order_roots
34
36
  min_where = if minimum_sort_order_value
35
37
  "AND #{quoted_order_column} >= #{minimum_sort_order_value}"
36
38
  else
37
39
  ""
38
40
  end
39
- connection.execute <<-SQL.strip_heredoc
41
+ connection.execute <<-SQL.squish
40
42
  UPDATE #{quoted_table_name}
41
43
  SET #{quoted_order_column(false)} = t.seq + #{minimum_sort_order_value.to_i - 1}
42
44
  FROM (
@@ -44,7 +46,8 @@ module ClosureTree
44
46
  FROM #{quoted_table_name}
45
47
  WHERE #{where_eq(parent_column_name, parent_id)} #{min_where}
46
48
  ) AS t
47
- WHERE #{quoted_table_name}.#{quoted_id_column_name} = t.id
49
+ WHERE #{quoted_table_name}.#{quoted_id_column_name} = t.id and
50
+ #{quoted_table_name}.#{quoted_order_column(false)} is distinct from t.seq + #{minimum_sort_order_value.to_i - 1}
48
51
  SQL
49
52
  end
50
53
 
@@ -55,6 +58,7 @@ module ClosureTree
55
58
 
56
59
  module GenericAdapter
57
60
  def reorder_with_parent_id(parent_id, minimum_sort_order_value = nil)
61
+ return if parent_id.nil? && dont_order_roots
58
62
  scope = model_class.
59
63
  where(parent_column_sym => parent_id).
60
64
  order(nulls_last_order_by)
@@ -22,7 +22,8 @@ module ClosureTree
22
22
  :parent_column_name => 'parent_id',
23
23
  :dependent => :nullify, # or :destroy or :delete_all -- see the README
24
24
  :name_column => 'name',
25
- :with_advisory_lock => true
25
+ :with_advisory_lock => true,
26
+ :numeric_order => false
26
27
  }.merge(options)
27
28
  raise ArgumentError, "name_column can't be 'path'" if options[:name_column] == 'path'
28
29
  if order_is_numeric?
@@ -31,13 +32,15 @@ module ClosureTree
31
32
  end
32
33
 
33
34
  def hierarchy_class_for_model
34
- hierarchy_class = model_class.parent.const_set(short_hierarchy_class_name, Class.new(ActiveRecord::Base))
35
+ parent_class = ActiveSupport::VERSION::MAJOR >= 6 ? model_class.module_parent : model_class.parent
36
+ hierarchy_class = parent_class.const_set(short_hierarchy_class_name, Class.new(model_class.superclass))
35
37
  use_attr_accessible = use_attr_accessible?
36
38
  include_forbidden_attributes_protection = include_forbidden_attributes_protection?
37
- hierarchy_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
39
+ model_class_name = model_class.to_s
40
+ hierarchy_class.class_eval do
38
41
  include ActiveModel::ForbiddenAttributesProtection if include_forbidden_attributes_protection
39
- belongs_to :ancestor, :class_name => "#{model_class}"
40
- belongs_to :descendant, :class_name => "#{model_class}"
42
+ belongs_to :ancestor, class_name: model_class_name
43
+ belongs_to :descendant, class_name: model_class_name
41
44
  attr_accessible :ancestor, :descendant, :generations if use_attr_accessible
42
45
  def ==(other)
43
46
  self.class == other.class && ancestor_id == other.ancestor_id && descendant_id == other.descendant_id
@@ -46,7 +49,7 @@ module ClosureTree
46
49
  def hash
47
50
  ancestor_id.hash << 31 ^ descendant_id.hash
48
51
  end
49
- RUBY
52
+ end
50
53
  hierarchy_class.table_name = hierarchy_table_name
51
54
  hierarchy_class
52
55
  end
@@ -77,17 +80,20 @@ module ClosureTree
77
80
  end
78
81
 
79
82
  def belongs_to_with_optional_option(opts)
80
- [ActiveRecord::VERSION::MAJOR < 5 ? opts.except(:optional) : opts]
83
+ ActiveRecord::VERSION::MAJOR < 5 ? opts.except(:optional) : opts
81
84
  end
82
85
 
83
86
  # lambda-ize the order, but don't apply the default order_option
84
- def has_many_without_order_option(opts)
85
- [lambda { order(opts[:order]) }, opts.except(:order)]
87
+ def has_many_order_without_option(order_by_opt)
88
+ [lambda { order(order_by_opt.call) }]
86
89
  end
87
90
 
88
- def has_many_with_order_option(opts)
89
- order_options = [opts[:order], order_by].compact
90
- [lambda { order(order_options) }, opts.except(:order)]
91
+ def has_many_order_with_option(order_by_opt=nil)
92
+ order_options = [order_by_opt, order_by].compact
93
+ [lambda {
94
+ order_options = order_options.map { |o| o.is_a?(Proc) ? o.call : o }
95
+ order(order_options)
96
+ }]
91
97
  end
92
98
 
93
99
  def ids_from(scope)
@@ -75,8 +75,12 @@ module ClosureTree
75
75
  options[:order]
76
76
  end
77
77
 
78
+ def dont_order_roots
79
+ options[:dont_order_roots] || false
80
+ end
81
+
78
82
  def nulls_last_order_by
79
- "-#{quoted_order_column} #{order_by_order(reverse = true)}"
83
+ Arel.sql "-#{quoted_order_column} #{order_by_order(true)}"
80
84
  end
81
85
 
82
86
  def order_by_order(reverse = false)
@@ -110,5 +114,10 @@ module ClosureTree
110
114
  prefix = include_table_name ? "#{quoted_table_name}." : ""
111
115
  "#{prefix}#{connection.quote_column_name(order_column)}"
112
116
  end
117
+
118
+ # table_name alias keyword , like "AS". When used on table name alias, Oracle Database don't support used 'AS'
119
+ def t_alias_keyword
120
+ (ActiveRecord::Base.connection.adapter_name.to_sym == :OracleEnhanced) ? "" : "AS"
121
+ end
113
122
  end
114
123
  end
@@ -17,10 +17,7 @@ module ClosureTree
17
17
  end
18
18
 
19
19
  def order_is_numeric?
20
- # The table might not exist yet (in the case of ActiveRecord::Observer use, see issue 32)
21
- return false if !order_option? || !model_class.table_exists?
22
- c = model_class.columns_hash[order_column]
23
- c && c.type == :integer
20
+ options[:numeric_order]
24
21
  end
25
22
 
26
23
  def subclass?
@@ -1,3 +1,3 @@
1
1
  module ClosureTree
2
- VERSION = Gem::Version.new('6.5.0')
2
+ VERSION = Gem::Version.new('7.4.0')
3
3
  end
@@ -1,5 +1,6 @@
1
1
  require 'closure_tree/active_record_support'
2
2
  require 'forwardable'
3
+ require 'rails/generators'
3
4
  require 'rails/generators/active_record'
4
5
  require 'rails/generators/named_base'
5
6
 
@@ -41,6 +42,13 @@ module ClosureTree
41
42
  end
42
43
  end
43
44
 
45
+ def migration_version
46
+ major = ActiveRecord::VERSION::MAJOR
47
+ if major >= 5
48
+ "[#{major}.#{ActiveRecord::VERSION::MINOR}]"
49
+ end
50
+ end
51
+
44
52
  def self.next_migration_number(dirname)
45
53
  ActiveRecord::Generators::Base.next_migration_number(dirname)
46
54
  end
@@ -1,4 +1,4 @@
1
- class <%= migration_class_name %> < ActiveRecord::Migration
1
+ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
2
2
  def change
3
3
  create_table :<%= migration_name %>, id: false do |t|
4
4
  t.<%= primary_key_type %> :ancestor_id, null: false