closure_tree 9.2.0 → 9.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ace270a69b46da9e3ba8594cb5299e0eb3d9b222f3261c6304566cdbe1f5c7bd
4
- data.tar.gz: ea4dccd196cf9278d510f89fa62d5e892916f2effa82b9c08cf851305523d528
3
+ metadata.gz: b7af281d62397e19768240a93ab284f2ceb9b261f5c4c3d2ac7d288300212b6e
4
+ data.tar.gz: ef0cf220eb02fcfafb17034265ee6d3ceb514c13898bf8c71eab1915f2a156c6
5
5
  SHA512:
6
- metadata.gz: 6f915001574717ed1bbadf7f528a6d8d04aa1143cf3ca736db3c1649e72cf4e0d3a60303df86456b6f5ab86a8b1a819987475361a6497f2488f3768ff243dc68
7
- data.tar.gz: 865de6b37ea0db45a27852799e2853b37091f32e06e28a405123e429275a05c7591069039f878cf26f6064e0e5d58ad67c9d227ef8911c2092667f009b4610f5
6
+ metadata.gz: 0a04211a36264ac886ecb8b7260c23e767de8364725fdc39dbfbe508116a5201ec8a54f4edf1865b3b91c8ee0fca4d2152d7f1091fca056501995723e4edbce6
7
+ data.tar.gz: d0ac8d6a477e357ff6582079443b6221100d868e1234d7aaaa4a815108b0db2e024e84086e2ace0c3dc3d1b85b8cd40701737f4bc292dabf479ca6263bccd994
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [9.3.0](https://github.com/ClosureTree/closure_tree/compare/closure_tree/v9.2.0...closure_tree/v9.3.0) (2025-11-19)
4
+
5
+
6
+ ### Features
7
+
8
+ * eager loading of hierarchies by defining the primary key ([#465](https://github.com/ClosureTree/closure_tree/issues/465)) ([8c7e490](https://github.com/ClosureTree/closure_tree/commit/8c7e490ff89239e44aee71577288bff0e177a8d6))
9
+
3
10
  ## [9.2.0](https://github.com/ClosureTree/closure_tree/compare/closure_tree/v9.1.1...closure_tree/v9.2.0) (2025-10-17)
4
11
 
5
12
 
data/README.md CHANGED
@@ -319,6 +319,7 @@ When you include ```has_closure_tree``` in your model, you can provide a hash to
319
319
  * ```nil``` does nothing with descendant nodes
320
320
  * ```: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").
321
321
  * ```:order``` used to set up [deterministic ordering](#deterministic-ordering)
322
+ * ```:scope``` restricts root nodes and sibling ordering to specific columns. Can be a single symbol or an array of symbols. Example: ```scope: :user_id``` or ```scope: [:user_id, :group_id]```. This ensures that root nodes and siblings are scoped correctly when reordering. See [Ordering Roots](#ordering-roots) for more details.
322
323
  * ```:touch``` delegates to the `belongs_to` annotation for the parent, so `touch`ing cascades to all children (the performance of this for deep trees isn't currently optimal).
323
324
 
324
325
  ## Accessing Data
@@ -491,9 +492,30 @@ table. So for instance if you have 5 nodes with no parent, they will be ordered
491
492
  If your model represents many separate trees and you have a lot of records, this can cause performance
492
493
  problems, and doesn't really make much sense.
493
494
 
494
- You can disable this default behavior by passing `dont_order_roots: true` as an option to your delcaration:
495
+ You can scope root nodes and sibling ordering by passing the `scope` option:
495
496
 
497
+ ```ruby
498
+ class Block < ApplicationRecord
499
+ has_closure_tree order: 'sort_order', numeric_order: true, scope: :user_id
500
+ end
496
501
  ```
502
+
503
+ This ensures that:
504
+ * Root nodes are scoped by the specified columns. You can filter roots like: ```Block.roots.where(user_id: 123)```
505
+ * Sibling reordering only affects nodes with the same scope values
506
+ * Children reordering respects the parent's scope values
507
+
508
+ You can also scope by multiple columns:
509
+
510
+ ```ruby
511
+ class Block < ApplicationRecord
512
+ has_closure_tree order: 'sort_order', numeric_order: true, scope: [:user_id, :group_id]
513
+ end
514
+ ```
515
+
516
+ Alternatively, you can disable root ordering entirely by passing `dont_order_roots: true`:
517
+
518
+ ```ruby
497
519
  has_closure_tree order: 'sort_order', numeric_order: true, dont_order_roots: true
498
520
  ```
499
521
 
@@ -14,7 +14,8 @@ module ClosureTree
14
14
  :numeric_order,
15
15
  :touch,
16
16
  :with_advisory_lock,
17
- :advisory_lock_name
17
+ :advisory_lock_name,
18
+ :scope
18
19
  )
19
20
 
20
21
  class_attribute :_ct
@@ -80,7 +80,9 @@ module ClosureTree
80
80
  end
81
81
 
82
82
  def self_and_siblings
83
- _ct.scope_with_order(_ct.base_class.where(_ct.parent_column_sym => _ct_parent_id))
83
+ scope = _ct.base_class.where(_ct.parent_column_sym => _ct_parent_id)
84
+ scope = _ct.apply_scope_conditions(scope, self)
85
+ _ct.scope_with_order(scope)
84
86
  end
85
87
 
86
88
  def siblings
@@ -15,16 +15,19 @@ module ClosureTree
15
15
  return unless saved_change_to_attribute?(_ct.parent_column_name) && !@was_new_record
16
16
 
17
17
  was_parent_id = attribute_before_last_save(_ct.parent_column_name)
18
- _ct.reorder_with_parent_id(was_parent_id)
18
+ scope_conditions = _ct.scope_values_from_instance(self)
19
+ _ct.reorder_with_parent_id(was_parent_id, nil, scope_conditions)
19
20
  end
20
21
 
21
22
  def _ct_reorder_siblings(minimum_sort_order_value = nil)
22
- _ct.reorder_with_parent_id(_ct_parent_id, minimum_sort_order_value)
23
+ scope_conditions = _ct.scope_values_from_instance(self)
24
+ _ct.reorder_with_parent_id(_ct_parent_id, minimum_sort_order_value, scope_conditions)
23
25
  reload unless destroyed?
24
26
  end
25
27
 
26
28
  def _ct_reorder_children(minimum_sort_order_value = nil)
27
- _ct.reorder_with_parent_id(_ct_id, minimum_sort_order_value)
29
+ scope_conditions = _ct.scope_values_from_instance(self)
30
+ _ct.reorder_with_parent_id(_ct_id, minimum_sort_order_value, scope_conditions)
28
31
  end
29
32
 
30
33
  def self_and_descendants_preordered
@@ -14,7 +14,7 @@ module ClosureTree
14
14
  end
15
15
 
16
16
  module MysqlAdapter
17
- def reorder_with_parent_id(parent_id, minimum_sort_order_value = nil)
17
+ def reorder_with_parent_id(parent_id, minimum_sort_order_value = nil, scope_conditions = {})
18
18
  return if parent_id.nil? && dont_order_roots
19
19
 
20
20
  min_where = if minimum_sort_order_value
@@ -22,18 +22,21 @@ module ClosureTree
22
22
  else
23
23
  ''
24
24
  end
25
+
26
+ scope_where = build_scope_where_clause(scope_conditions)
27
+
25
28
  connection.execute 'SET @i = 0'
26
29
  connection.execute <<-SQL.squish
27
30
  UPDATE #{quoted_table_name}
28
31
  SET #{quoted_order_column} = (@i := @i + 1) + #{minimum_sort_order_value.to_i - 1}
29
- WHERE #{where_eq(parent_column_name, parent_id)} #{min_where}
32
+ WHERE #{where_eq(parent_column_name, parent_id)} #{min_where}#{scope_where}
30
33
  ORDER BY #{nulls_last_order_by}
31
34
  SQL
32
35
  end
33
36
  end
34
37
 
35
38
  module PostgreSQLAdapter
36
- def reorder_with_parent_id(parent_id, minimum_sort_order_value = nil)
39
+ def reorder_with_parent_id(parent_id, minimum_sort_order_value = nil, scope_conditions = {})
37
40
  return if parent_id.nil? && dont_order_roots
38
41
 
39
42
  min_where = if minimum_sort_order_value
@@ -41,13 +44,16 @@ module ClosureTree
41
44
  else
42
45
  ''
43
46
  end
47
+
48
+ scope_where = build_scope_where_clause(scope_conditions)
49
+
44
50
  connection.execute <<-SQL.squish
45
51
  UPDATE #{quoted_table_name}
46
52
  SET #{quoted_order_column(false)} = t.seq + #{minimum_sort_order_value.to_i - 1}
47
53
  FROM (
48
54
  SELECT #{quoted_id_column_name} AS id, row_number() OVER(ORDER BY #{order_by}) AS seq
49
55
  FROM #{quoted_table_name}
50
- WHERE #{where_eq(parent_column_name, parent_id)} #{min_where}
56
+ WHERE #{where_eq(parent_column_name, parent_id)} #{min_where}#{scope_where}
51
57
  ) AS t
52
58
  WHERE #{quoted_table_name}.#{quoted_id_column_name} = t.id and
53
59
  #{quoted_table_name}.#{quoted_order_column(false)} is distinct from t.seq + #{minimum_sort_order_value.to_i - 1}
@@ -60,12 +66,13 @@ module ClosureTree
60
66
  end
61
67
 
62
68
  module GenericAdapter
63
- def reorder_with_parent_id(parent_id, minimum_sort_order_value = nil)
69
+ def reorder_with_parent_id(parent_id, minimum_sort_order_value = nil, scope_conditions = {})
64
70
  return if parent_id.nil? && dont_order_roots
65
71
 
66
72
  scope = model_class
67
73
  .where(parent_column_sym => parent_id)
68
74
  .order(nulls_last_order_by)
75
+ scope = scope.where(scope_conditions) if scope_conditions.any?
69
76
  scope = scope.where("#{quoted_order_column} >= #{minimum_sort_order_value}") if minimum_sort_order_value
70
77
  scope.each_with_index do |ea, idx|
71
78
  ea.update_order_value(idx + minimum_sort_order_value.to_i)
@@ -23,6 +23,13 @@ module ClosureTree
23
23
  }.merge(options)
24
24
  raise ArgumentError, "name_column can't be 'path'" if options[:name_column] == 'path'
25
25
 
26
+ if options[:scope]
27
+ scope_option = options[:scope]
28
+ unless scope_option.is_a?(Symbol) || (scope_option.is_a?(Array) && scope_option.all? { |item| item.is_a?(Symbol) })
29
+ raise ArgumentError, "scope option must be a Symbol or an Array of Symbols (e.g., :user_id or [:user_id, :group_id])"
30
+ end
31
+ end
32
+
26
33
  return unless order_is_numeric?
27
34
 
28
35
  extend NumericOrderSupport.adapter_for_connection(connection)
@@ -36,6 +43,10 @@ module ClosureTree
36
43
  # Rails 8.1+ requires an implicit_order_column for models without a primary key
37
44
  self.implicit_order_column = 'ancestor_id'
38
45
 
46
+ # Rails uses the primary key to correctly match associations when using a join to preload (e.g. via `eager_load`).
47
+ # The migration generator adds a unique index across these three columns so this is safe.
48
+ self.primary_key = [:ancestor_id, :descendant_id, :generations]
49
+
39
50
  belongs_to :ancestor, class_name: model_class_name
40
51
  belongs_to :descendant, class_name: model_class_name
41
52
  def ==(other)
@@ -109,6 +120,22 @@ module ClosureTree
109
120
  end
110
121
  end
111
122
 
123
+ # Builds SQL WHERE conditions for scope columns
124
+ # Returns a string that can be appended to a WHERE clause
125
+ def build_scope_where_clause(scope_conditions)
126
+ return '' unless scope_conditions.is_a?(Hash) && scope_conditions.any?
127
+
128
+ conditions = scope_conditions.map do |column, value|
129
+ if value.nil?
130
+ "#{connection.quote_column_name(column.to_s)} IS NULL"
131
+ else
132
+ "#{connection.quote_column_name(column.to_s)} = #{quoted_value(value)}"
133
+ end
134
+ end
135
+
136
+ " AND #{conditions.join(' AND ')}"
137
+ end
138
+
112
139
  def with_advisory_lock(&block)
113
140
  if options[:with_advisory_lock] && connection.supports_advisory_locks? && model_class.respond_to?(:with_advisory_lock)
114
141
  model_class.with_advisory_lock(advisory_lock_name) do
@@ -170,5 +197,49 @@ module ClosureTree
170
197
  def create!(model_class, attributes)
171
198
  create(model_class, attributes).tap(&:save!)
172
199
  end
200
+
201
+ def scope_columns
202
+ return [] unless options[:scope]
203
+
204
+ scope_option = options[:scope]
205
+
206
+ case scope_option
207
+ when Symbol
208
+ [scope_option]
209
+ when Array
210
+ scope_option.select { |item| item.is_a?(Symbol) }
211
+ else
212
+ []
213
+ end
214
+ end
215
+
216
+ def scope_values_from_instance(instance)
217
+ return {} unless options[:scope] && instance
218
+
219
+ scope_option = options[:scope]
220
+ scope_hash = {}
221
+
222
+ case scope_option
223
+ when Symbol
224
+ value = instance.read_attribute(scope_option)
225
+ scope_hash[scope_option] = value unless value.nil?
226
+ when Array
227
+ scope_option.each do |item|
228
+ if item.is_a?(Symbol)
229
+ value = instance.read_attribute(item)
230
+ scope_hash[item] = value unless value.nil?
231
+ end
232
+ end
233
+ end
234
+
235
+ scope_hash
236
+ end
237
+
238
+ def apply_scope_conditions(scope, instance = nil)
239
+ return scope unless options[:scope] && instance
240
+
241
+ scope_values = scope_values_from_instance(instance)
242
+ scope_values.any? ? scope.where(scope_values) : scope
243
+ end
173
244
  end
174
245
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClosureTree
4
- VERSION = Gem::Version.new('9.2.0')
4
+ VERSION = Gem::Version.new('9.3.0')
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: closure_tree
3
3
  version: !ruby/object:Gem::Version
4
- version: 9.2.0
4
+ version: 9.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthew McEachen
@@ -178,7 +178,7 @@ licenses:
178
178
  metadata:
179
179
  bug_tracker_uri: https://github.com/ClosureTree/closure_tree/issues
180
180
  changelog_uri: https://github.com/ClosureTree/closure_tree/blob/master/CHANGELOG.md
181
- documentation_uri: https://www.rubydoc.info/gems/closure_tree/9.2.0
181
+ documentation_uri: https://www.rubydoc.info/gems/closure_tree/9.3.0
182
182
  homepage_uri: https://closuretree.github.io/closure_tree/
183
183
  source_code_uri: https://github.com/ClosureTree/closure_tree
184
184
  rubygems_mfa_required: 'true'