closure_tree 9.1.1 → 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 +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +24 -2
- data/lib/closure_tree/has_closure_tree.rb +2 -1
- data/lib/closure_tree/model.rb +3 -1
- data/lib/closure_tree/numeric_deterministic_ordering.rb +6 -3
- data/lib/closure_tree/numeric_order_support.rb +12 -5
- data/lib/closure_tree/support.rb +87 -2
- data/lib/closure_tree/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b7af281d62397e19768240a93ab284f2ceb9b261f5c4c3d2ac7d288300212b6e
|
|
4
|
+
data.tar.gz: ef0cf220eb02fcfafb17034265ee6d3ceb514c13898bf8c71eab1915f2a156c6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0a04211a36264ac886ecb8b7260c23e767de8364725fdc39dbfbe508116a5201ec8a54f4edf1865b3b91c8ee0fca4d2152d7f1091fca056501995723e4edbce6
|
|
7
|
+
data.tar.gz: d0ac8d6a477e357ff6582079443b6221100d868e1234d7aaaa4a815108b0db2e024e84086e2ace0c3dc3d1b85b8cd40701737f4bc292dabf479ca6263bccd994
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
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
|
+
|
|
10
|
+
## [9.2.0](https://github.com/ClosureTree/closure_tree/compare/closure_tree/v9.1.1...closure_tree/v9.2.0) (2025-10-17)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* add PostgreSQL schema-qualified table name support ([#462](https://github.com/ClosureTree/closure_tree/issues/462)) ([5f9006c](https://github.com/ClosureTree/closure_tree/commit/5f9006cece95a76f665cb50c2615317e3fa48586))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* add implicit_order_column for Rails 8.1+ compatibility ([#464](https://github.com/ClosureTree/closure_tree/issues/464)) ([f384303](https://github.com/ClosureTree/closure_tree/commit/f38430334a79ea15d236f9212118dcb5e4530746))
|
|
21
|
+
|
|
3
22
|
## [9.1.1](https://github.com/ClosureTree/closure_tree/compare/closure_tree/v9.1.0...closure_tree/v9.1.1) (2025-07-24)
|
|
4
23
|
|
|
5
24
|
|
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
|
|
@@ -343,7 +344,7 @@ When you include ```has_closure_tree``` in your model, you can provide a hash to
|
|
|
343
344
|
* ```tag.child?``` returns true if this is a child node. It has a parent.
|
|
344
345
|
* ```tag.leaf?``` returns true if this is a leaf node. It has no children.
|
|
345
346
|
* ```tag.leaves``` is scoped to all leaf nodes in self_and_descendants.
|
|
346
|
-
* ```tag.depth``` returns the depth, or "generation", for this node in the tree. A root node will have a value of 0.
|
|
347
|
+
* ```tag.depth``` returns the depth, or "generation", for this node in the tree. A root node will have a value of 0. Also aliased as `level`.
|
|
347
348
|
* ```tag.parent``` returns the node's immediate parent. Root nodes will return nil.
|
|
348
349
|
* ```tag.parent_of?(node)``` returns true if current node is parent of another one
|
|
349
350
|
* ```tag.children``` is a ```has_many``` of immediate children (just those nodes whose parent is the current node).
|
|
@@ -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
|
|
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
|
|
data/lib/closure_tree/model.rb
CHANGED
|
@@ -80,7 +80,9 @@ module ClosureTree
|
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
def self_and_siblings
|
|
83
|
-
_ct.
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|
data/lib/closure_tree/support.rb
CHANGED
|
@@ -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)
|
|
@@ -33,6 +40,13 @@ module ClosureTree
|
|
|
33
40
|
hierarchy_class = parent_class.const_set(short_hierarchy_class_name, Class.new(model_class.superclass))
|
|
34
41
|
model_class_name = model_class.to_s
|
|
35
42
|
hierarchy_class.class_eval do
|
|
43
|
+
# Rails 8.1+ requires an implicit_order_column for models without a primary key
|
|
44
|
+
self.implicit_order_column = 'ancestor_id'
|
|
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
|
+
|
|
36
50
|
belongs_to :ancestor, class_name: model_class_name
|
|
37
51
|
belongs_to :descendant, class_name: model_class_name
|
|
38
52
|
def ==(other)
|
|
@@ -51,8 +65,19 @@ module ClosureTree
|
|
|
51
65
|
# We need to use the table_name, not something like ct_class.to_s.demodulize + "_hierarchies",
|
|
52
66
|
# because they may have overridden the table name, which is what we want to be consistent with
|
|
53
67
|
# in order for the schema to make sense.
|
|
54
|
-
|
|
55
|
-
|
|
68
|
+
if options[:hierarchy_table_name]
|
|
69
|
+
tablename = options[:hierarchy_table_name]
|
|
70
|
+
else
|
|
71
|
+
base_table = remove_prefix_and_suffix(table_name, model_class)
|
|
72
|
+
|
|
73
|
+
# Handle PostgreSQL schema-qualified table names (e.g., "my_schema.table_name")
|
|
74
|
+
schema, _, table = base_table.rpartition('.')
|
|
75
|
+
if schema.present?
|
|
76
|
+
tablename = "#{schema}.#{table.singularize}_hierarchies"
|
|
77
|
+
else
|
|
78
|
+
tablename = "#{table.singularize}_hierarchies"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
56
81
|
|
|
57
82
|
[model_class.table_name_prefix, tablename, model_class.table_name_suffix].join
|
|
58
83
|
end
|
|
@@ -95,6 +120,22 @@ module ClosureTree
|
|
|
95
120
|
end
|
|
96
121
|
end
|
|
97
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
|
+
|
|
98
139
|
def with_advisory_lock(&block)
|
|
99
140
|
if options[:with_advisory_lock] && connection.supports_advisory_locks? && model_class.respond_to?(:with_advisory_lock)
|
|
100
141
|
model_class.with_advisory_lock(advisory_lock_name) do
|
|
@@ -156,5 +197,49 @@ module ClosureTree
|
|
|
156
197
|
def create!(model_class, attributes)
|
|
157
198
|
create(model_class, attributes).tap(&:save!)
|
|
158
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
|
|
159
244
|
end
|
|
160
245
|
end
|
data/lib/closure_tree/version.rb
CHANGED
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.
|
|
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.
|
|
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'
|