Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ When you include ```has_closure_tree``` in your model, you can provide a hash to
* ```nil``` does nothing with descendant nodes
* ```: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").
* ```:order``` used to set up [deterministic ordering](#deterministic-ordering)
* ```: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.
* ```: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).

## Accessing Data
Expand Down Expand Up @@ -491,9 +492,30 @@ table. So for instance if you have 5 nodes with no parent, they will be ordered
If your model represents many separate trees and you have a lot of records, this can cause performance
problems, and doesn't really make much sense.

You can disable this default behavior by passing `dont_order_roots: true` as an option to your delcaration:
You can scope root nodes and sibling ordering by passing the `scope` option:

```ruby
class Block < ApplicationRecord
has_closure_tree order: 'sort_order', numeric_order: true, scope: :user_id
end
```

This ensures that:
* Root nodes are scoped by the specified columns. You can filter roots like: ```Block.roots.where(user_id: 123)```
* Sibling reordering only affects nodes with the same scope values
* Children reordering respects the parent's scope values

You can also scope by multiple columns:

```ruby
class Block < ApplicationRecord
has_closure_tree order: 'sort_order', numeric_order: true, scope: [:user_id, :group_id]
end
```

Alternatively, you can disable root ordering entirely by passing `dont_order_roots: true`:

```ruby
has_closure_tree order: 'sort_order', numeric_order: true, dont_order_roots: true
```

Expand Down
3 changes: 2 additions & 1 deletion lib/closure_tree/has_closure_tree.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ def has_closure_tree(options = {})
:numeric_order,
:touch,
:with_advisory_lock,
:advisory_lock_name
:advisory_lock_name,
:scope
)

class_attribute :_ct
Expand Down
4 changes: 3 additions & 1 deletion lib/closure_tree/model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ def descendant_ids
end

def self_and_siblings
_ct.scope_with_order(_ct.base_class.where(_ct.parent_column_sym => _ct_parent_id))
scope = _ct.base_class.where(_ct.parent_column_sym => _ct_parent_id)
scope = _ct.apply_scope_conditions(scope, self)
_ct.scope_with_order(scope)
end

def siblings
Expand Down
9 changes: 6 additions & 3 deletions lib/closure_tree/numeric_deterministic_ordering.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,19 @@ def _ct_reorder_prior_siblings_if_parent_changed
return unless saved_change_to_attribute?(_ct.parent_column_name) && !@was_new_record

was_parent_id = attribute_before_last_save(_ct.parent_column_name)
_ct.reorder_with_parent_id(was_parent_id)
scope_conditions = _ct.scope_values_from_instance(self)
_ct.reorder_with_parent_id(was_parent_id, nil, scope_conditions)
end

def _ct_reorder_siblings(minimum_sort_order_value = nil)
_ct.reorder_with_parent_id(_ct_parent_id, minimum_sort_order_value)
scope_conditions = _ct.scope_values_from_instance(self)
_ct.reorder_with_parent_id(_ct_parent_id, minimum_sort_order_value, scope_conditions)
reload unless destroyed?
end

def _ct_reorder_children(minimum_sort_order_value = nil)
_ct.reorder_with_parent_id(_ct_id, minimum_sort_order_value)
scope_conditions = _ct.scope_values_from_instance(self)
_ct.reorder_with_parent_id(_ct_id, minimum_sort_order_value, scope_conditions)
end

def self_and_descendants_preordered
Expand Down
17 changes: 12 additions & 5 deletions lib/closure_tree/numeric_order_support.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,40 +14,46 @@ def self.adapter_for_connection(connection)
end

module MysqlAdapter
def reorder_with_parent_id(parent_id, minimum_sort_order_value = nil)
def reorder_with_parent_id(parent_id, minimum_sort_order_value = nil, scope_conditions = {})
return if parent_id.nil? && dont_order_roots

min_where = if minimum_sort_order_value
"AND #{quoted_order_column} >= #{minimum_sort_order_value}"
else
''
end

scope_where = build_scope_where_clause(scope_conditions)

connection.execute 'SET @i = 0'
connection.execute <<-SQL.squish
UPDATE #{quoted_table_name}
SET #{quoted_order_column} = (@i := @i + 1) + #{minimum_sort_order_value.to_i - 1}
WHERE #{where_eq(parent_column_name, parent_id)} #{min_where}
WHERE #{where_eq(parent_column_name, parent_id)} #{min_where}#{scope_where}
ORDER BY #{nulls_last_order_by}
SQL
end
end

module PostgreSQLAdapter
def reorder_with_parent_id(parent_id, minimum_sort_order_value = nil)
def reorder_with_parent_id(parent_id, minimum_sort_order_value = nil, scope_conditions = {})
return if parent_id.nil? && dont_order_roots

min_where = if minimum_sort_order_value
"AND #{quoted_order_column} >= #{minimum_sort_order_value}"
else
''
end

scope_where = build_scope_where_clause(scope_conditions)

connection.execute <<-SQL.squish
UPDATE #{quoted_table_name}
SET #{quoted_order_column(false)} = t.seq + #{minimum_sort_order_value.to_i - 1}
FROM (
SELECT #{quoted_id_column_name} AS id, row_number() OVER(ORDER BY #{order_by}) AS seq
FROM #{quoted_table_name}
WHERE #{where_eq(parent_column_name, parent_id)} #{min_where}
WHERE #{where_eq(parent_column_name, parent_id)} #{min_where}#{scope_where}
) AS t
WHERE #{quoted_table_name}.#{quoted_id_column_name} = t.id and
#{quoted_table_name}.#{quoted_order_column(false)} is distinct from t.seq + #{minimum_sort_order_value.to_i - 1}
Expand All @@ -60,12 +66,13 @@ def rows_updated(result)
end

module GenericAdapter
def reorder_with_parent_id(parent_id, minimum_sort_order_value = nil)
def reorder_with_parent_id(parent_id, minimum_sort_order_value = nil, scope_conditions = {})
return if parent_id.nil? && dont_order_roots

scope = model_class
.where(parent_column_sym => parent_id)
.order(nulls_last_order_by)
scope = scope.where(scope_conditions) if scope_conditions.any?
scope = scope.where("#{quoted_order_column} >= #{minimum_sort_order_value}") if minimum_sort_order_value
scope.each_with_index do |ea, idx|
ea.update_order_value(idx + minimum_sort_order_value.to_i)
Expand Down
67 changes: 67 additions & 0 deletions lib/closure_tree/support.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ def initialize(model_class, options)
}.merge(options)
raise ArgumentError, "name_column can't be 'path'" if options[:name_column] == 'path'

if options[:scope]
scope_option = options[:scope]
unless scope_option.is_a?(Symbol) || (scope_option.is_a?(Array) && scope_option.all? { |item| item.is_a?(Symbol) })
raise ArgumentError, "scope option must be a Symbol or an Array of Symbols (e.g., :user_id or [:user_id, :group_id])"
end
end

return unless order_is_numeric?

extend NumericOrderSupport.adapter_for_connection(connection)
Expand Down Expand Up @@ -109,6 +116,22 @@ def where_eq(column_name, value)
end
end

# Builds SQL WHERE conditions for scope columns
# Returns a string that can be appended to a WHERE clause
def build_scope_where_clause(scope_conditions)
return '' unless scope_conditions.is_a?(Hash) && scope_conditions.any?

conditions = scope_conditions.map do |column, value|
if value.nil?
"#{connection.quote_column_name(column.to_s)} IS NULL"
else
"#{connection.quote_column_name(column.to_s)} = #{quoted_value(value)}"
end
end

" AND #{conditions.join(' AND ')}"
end

def with_advisory_lock(&block)
if options[:with_advisory_lock] && connection.supports_advisory_locks? && model_class.respond_to?(:with_advisory_lock)
model_class.with_advisory_lock(advisory_lock_name) do
Expand Down Expand Up @@ -170,5 +193,49 @@ def create(model_class, attributes)
def create!(model_class, attributes)
create(model_class, attributes).tap(&:save!)
end

def scope_columns
return [] unless options[:scope]

scope_option = options[:scope]

case scope_option
when Symbol
[scope_option]
when Array
scope_option.select { |item| item.is_a?(Symbol) }
else
[]
end
end

def scope_values_from_instance(instance)
return {} unless options[:scope] && instance

scope_option = options[:scope]
scope_hash = {}

case scope_option
when Symbol
value = instance.read_attribute(scope_option)
scope_hash[scope_option] = value unless value.nil?
when Array
scope_option.each do |item|
if item.is_a?(Symbol)
value = instance.read_attribute(item)
scope_hash[item] = value unless value.nil?
end
end
end

scope_hash
end

def apply_scope_conditions(scope, instance = nil)
return scope unless options[:scope] && instance

scope_values = scope_values_from_instance(instance)
scope_values.any? ? scope.where(scope_values) : scope
end
end
end
Loading