Skip to content

Commit 87be762

Browse files
authored
Add scope option for root nodes and sibling ordering (#466)
close #442
1 parent b6f19d2 commit 87be762

File tree

10 files changed

+324
-11
lines changed

10 files changed

+324
-11
lines changed

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ When you include ```has_closure_tree``` in your model, you can provide a hash to
319319
* ```nil``` does nothing with descendant nodes
320320
* ```: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").
321321
* ```: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.
322323
* ```: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).
323324

324325
## Accessing Data
@@ -491,9 +492,30 @@ table. So for instance if you have 5 nodes with no parent, they will be ordered
491492
If your model represents many separate trees and you have a lot of records, this can cause performance
492493
problems, and doesn't really make much sense.
493494
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:
495496
497+
```ruby
498+
class Block < ApplicationRecord
499+
has_closure_tree order: 'sort_order', numeric_order: true, scope: :user_id
500+
end
496501
```
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
497519
has_closure_tree order: 'sort_order', numeric_order: true, dont_order_roots: true
498520
```
499521

lib/closure_tree/has_closure_tree.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ def has_closure_tree(options = {})
1414
:numeric_order,
1515
:touch,
1616
:with_advisory_lock,
17-
:advisory_lock_name
17+
:advisory_lock_name,
18+
:scope
1819
)
1920

2021
class_attribute :_ct

lib/closure_tree/model.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ def descendant_ids
8080
end
8181

8282
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)
8486
end
8587

8688
def siblings

lib/closure_tree/numeric_deterministic_ordering.rb

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,19 @@ def _ct_reorder_prior_siblings_if_parent_changed
1515
return unless saved_change_to_attribute?(_ct.parent_column_name) && !@was_new_record
1616

1717
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)
1920
end
2021

2122
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)
2325
reload unless destroyed?
2426
end
2527

2628
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)
2831
end
2932

3033
def self_and_descendants_preordered

lib/closure_tree/numeric_order_support.rb

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,40 +14,46 @@ def self.adapter_for_connection(connection)
1414
end
1515

1616
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 = {})
1818
return if parent_id.nil? && dont_order_roots
1919

2020
min_where = if minimum_sort_order_value
2121
"AND #{quoted_order_column} >= #{minimum_sort_order_value}"
2222
else
2323
''
2424
end
25+
26+
scope_where = build_scope_where_clause(scope_conditions)
27+
2528
connection.execute 'SET @i = 0'
2629
connection.execute <<-SQL.squish
2730
UPDATE #{quoted_table_name}
2831
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}
3033
ORDER BY #{nulls_last_order_by}
3134
SQL
3235
end
3336
end
3437

3538
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 = {})
3740
return if parent_id.nil? && dont_order_roots
3841

3942
min_where = if minimum_sort_order_value
4043
"AND #{quoted_order_column} >= #{minimum_sort_order_value}"
4144
else
4245
''
4346
end
47+
48+
scope_where = build_scope_where_clause(scope_conditions)
49+
4450
connection.execute <<-SQL.squish
4551
UPDATE #{quoted_table_name}
4652
SET #{quoted_order_column(false)} = t.seq + #{minimum_sort_order_value.to_i - 1}
4753
FROM (
4854
SELECT #{quoted_id_column_name} AS id, row_number() OVER(ORDER BY #{order_by}) AS seq
4955
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}
5157
) AS t
5258
WHERE #{quoted_table_name}.#{quoted_id_column_name} = t.id and
5359
#{quoted_table_name}.#{quoted_order_column(false)} is distinct from t.seq + #{minimum_sort_order_value.to_i - 1}
@@ -60,12 +66,13 @@ def rows_updated(result)
6066
end
6167

6268
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 = {})
6470
return if parent_id.nil? && dont_order_roots
6571

6672
scope = model_class
6773
.where(parent_column_sym => parent_id)
6874
.order(nulls_last_order_by)
75+
scope = scope.where(scope_conditions) if scope_conditions.any?
6976
scope = scope.where("#{quoted_order_column} >= #{minimum_sort_order_value}") if minimum_sort_order_value
7077
scope.each_with_index do |ea, idx|
7178
ea.update_order_value(idx + minimum_sort_order_value.to_i)

lib/closure_tree/support.rb

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ def initialize(model_class, options)
2323
}.merge(options)
2424
raise ArgumentError, "name_column can't be 'path'" if options[:name_column] == 'path'
2525

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+
2633
return unless order_is_numeric?
2734

2835
extend NumericOrderSupport.adapter_for_connection(connection)
@@ -109,6 +116,22 @@ def where_eq(column_name, value)
109116
end
110117
end
111118

119+
# Builds SQL WHERE conditions for scope columns
120+
# Returns a string that can be appended to a WHERE clause
121+
def build_scope_where_clause(scope_conditions)
122+
return '' unless scope_conditions.is_a?(Hash) && scope_conditions.any?
123+
124+
conditions = scope_conditions.map do |column, value|
125+
if value.nil?
126+
"#{connection.quote_column_name(column.to_s)} IS NULL"
127+
else
128+
"#{connection.quote_column_name(column.to_s)} = #{quoted_value(value)}"
129+
end
130+
end
131+
132+
" AND #{conditions.join(' AND ')}"
133+
end
134+
112135
def with_advisory_lock(&block)
113136
if options[:with_advisory_lock] && connection.supports_advisory_locks? && model_class.respond_to?(:with_advisory_lock)
114137
model_class.with_advisory_lock(advisory_lock_name) do
@@ -170,5 +193,49 @@ def create(model_class, attributes)
170193
def create!(model_class, attributes)
171194
create(model_class, attributes).tap(&:save!)
172195
end
196+
197+
def scope_columns
198+
return [] unless options[:scope]
199+
200+
scope_option = options[:scope]
201+
202+
case scope_option
203+
when Symbol
204+
[scope_option]
205+
when Array
206+
scope_option.select { |item| item.is_a?(Symbol) }
207+
else
208+
[]
209+
end
210+
end
211+
212+
def scope_values_from_instance(instance)
213+
return {} unless options[:scope] && instance
214+
215+
scope_option = options[:scope]
216+
scope_hash = {}
217+
218+
case scope_option
219+
when Symbol
220+
value = instance.read_attribute(scope_option)
221+
scope_hash[scope_option] = value unless value.nil?
222+
when Array
223+
scope_option.each do |item|
224+
if item.is_a?(Symbol)
225+
value = instance.read_attribute(item)
226+
scope_hash[item] = value unless value.nil?
227+
end
228+
end
229+
end
230+
231+
scope_hash
232+
end
233+
234+
def apply_scope_conditions(scope, instance = nil)
235+
return scope unless options[:scope] && instance
236+
237+
scope_values = scope_values_from_instance(instance)
238+
scope_values.any? ? scope.where(scope_values) : scope
239+
end
173240
end
174241
end

0 commit comments

Comments
 (0)