From 7f66e721af1f9df6903ca33bdfa39bc2aee3b4f0 Mon Sep 17 00:00:00 2001 From: Sampat Badhe Date: Thu, 29 Jan 2026 16:38:17 +0530 Subject: [PATCH 1/6] Fix root node reordering when moving to child position Updates prepend_child to reorder remaining root nodes when a root node becomes a child, ensuring order_values remain sequential. Adds tests to verify correct reordering for both prepend_child and append_child operations. --- .../numeric_deterministic_ordering.rb | 5 ++ test/closure_tree/label_order_value_test.rb | 62 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/lib/closure_tree/numeric_deterministic_ordering.rb b/lib/closure_tree/numeric_deterministic_ordering.rb index ab49f321..507e9622 100644 --- a/lib/closure_tree/numeric_deterministic_ordering.rb +++ b/lib/closure_tree/numeric_deterministic_ordering.rb @@ -130,11 +130,16 @@ def append_child(child_node) end def prepend_child(child_node) + prior_parent_id = child_node._ct_parent_id child_node.order_value = -1 child_node.parent = self child_node._ct_skip_sort_order_maintenance! if child_node.save _ct_reorder_children + if prior_parent_id != _ct_id + scope_conditions = _ct.scope_values_from_instance(child_node) + _ct.reorder_with_parent_id(prior_parent_id, nil, scope_conditions) + end child_node.reload else child_node diff --git a/test/closure_tree/label_order_value_test.rb b/test/closure_tree/label_order_value_test.rb index 968a3323..6c472265 100644 --- a/test/closure_tree/label_order_value_test.rb +++ b/test/closure_tree/label_order_value_test.rb @@ -53,4 +53,66 @@ def setup assert_equal 1, b.order_value assert_equal 2, c.order_value end + + test 'should reorder remaining root nodes when a root node becomes a child via prepend_child' do + node_1 = Label.create(name: 'node_1') + node_2 = Label.create(name: 'node_2') + node_3 = Label.create(name: 'node_3') + node_4 = Label.create(name: 'node_4') + + # Verify initial positions + assert_equal 0, node_1.order_value + assert_equal 1, node_2.order_value + assert_equal 2, node_3.order_value + assert_equal 3, node_4.order_value + + # Move node_2 as a child of node_3 using prepend_child + node_3.prepend_child(node_2) + + # Reload all nodes to get updated positions + node_1.reload + node_2.reload + node_3.reload + node_4.reload + + # Verify node_2 is now a child of node_3 with position 0 + assert_equal node_3.id, node_2.parent_id + assert_equal 0, node_2.order_value + + # Verify remaining root nodes have sequential positions without gaps + assert_equal 0, node_1.order_value + assert_equal 1, node_3.order_value + assert_equal 2, node_4.order_value + end + + test 'should reorder remaining root nodes when a root node becomes a child via append_child' do + node_1 = Label.create(name: 'node_1') + node_2 = Label.create(name: 'node_2') + node_3 = Label.create(name: 'node_3') + node_4 = Label.create(name: 'node_4') + + # Verify initial positions + assert_equal 0, node_1.order_value + assert_equal 1, node_2.order_value + assert_equal 2, node_3.order_value + assert_equal 3, node_4.order_value + + # Move node_2 as a child of node_3 using append_child + node_3.append_child(node_2) + + # Reload all nodes to get updated positions + node_1.reload + node_2.reload + node_3.reload + node_4.reload + + # Verify node_2 is now a child of node_3 with position 0 + assert_equal node_3.id, node_2.parent_id + assert_equal 0, node_2.order_value + + # Verify remaining root nodes have sequential positions without gaps + assert_equal 0, node_1.order_value + assert_equal 1, node_3.order_value + assert_equal 2, node_4.order_value + end end From 77db2c72c79d8e73bb4b3233853267f365f3e618 Mon Sep 17 00:00:00 2001 From: Sampat Badhe Date: Sat, 14 Feb 2026 13:56:22 +0530 Subject: [PATCH 2/6] Maintain numeric order and add cross-db tests Ensure numeric ordering maintenance runs when the order column changes and add cross-database tests. - hierarchy_maintenance.rb: run rebuild or sibling reordering from _ct_after_save when the order column changes; remove single-use skip-sort flag behavior. - numeric_deterministic_ordering.rb: stop skipping sort-order maintenance in prepend/sibling operations so reordering happens correctly and avoid double reorders. - tests: add order_value_cross_database_test to validate reorder behavior across PostgreSQL (Label), SQLite (MemoryTag) and MySQL (SecondaryTag); update label tests to persist order with update_columns. - dummy/schema: enable numeric ordering by adding a sort_order column to test schemas and configuring MemoryTag and SecondaryTag with has_closure_tree order: :sort_order, numeric_order: true. --- lib/closure_tree/hierarchy_maintenance.rb | 13 +- .../numeric_deterministic_ordering.rb | 15 - test/closure_tree/label_order_value_test.rb | 62 ---- test/closure_tree/label_test.rb | 10 +- test/closure_tree/multi_database_test.rb | 1 + .../order_value_cross_database_test.rb | 297 ++++++++++++++++++ test/dummy/app/models/memory_tag.rb | 2 +- test/dummy/app/models/secondary_tag.rb | 2 +- test/dummy/db/secondary_schema.rb | 1 + 9 files changed, 312 insertions(+), 91 deletions(-) create mode 100644 test/closure_tree/order_value_cross_database_test.rb diff --git a/lib/closure_tree/hierarchy_maintenance.rb b/lib/closure_tree/hierarchy_maintenance.rb index 825181a6..843bf51c 100644 --- a/lib/closure_tree/hierarchy_maintenance.rb +++ b/lib/closure_tree/hierarchy_maintenance.rb @@ -17,10 +17,6 @@ def _ct_skip_cycle_detection! @_ct_skip_cycle_detection = true end - def _ct_skip_sort_order_maintenance! - @_ct_skip_sort_order_maintenance = true - end - def _ct_validate if !(defined? @_ct_skip_cycle_detection) && !new_record? && # don't validate for cycles if we're a new record @@ -38,7 +34,11 @@ def _ct_before_save end def _ct_after_save - rebuild! if saved_changes[_ct.parent_column_name] || @was_new_record + if saved_changes[_ct.parent_column_name] || @was_new_record + rebuild! + elsif saved_changes[_ct.order_column_sym] + _ct_reorder_siblings(saved_changes[_ct.order_column_sym].min) + end if saved_changes[_ct.parent_column_name] && !@was_new_record # Resetting the ancestral collections addresses # https://github.com/mceachen/closure_tree/issues/68 @@ -46,7 +46,6 @@ def _ct_after_save self_and_ancestors.reload end @was_new_record = false # we aren't new anymore. - @_ct_skip_sort_order_maintenance = false # only skip once. true # don't cancel anything. end @@ -86,7 +85,7 @@ def rebuild!(called_by_rebuild = false) SQL end - if _ct.order_is_numeric? && !@_ct_skip_sort_order_maintenance + if _ct.order_is_numeric? _ct_reorder_prior_siblings_if_parent_changed # Prevent double-reordering of siblings: _ct_reorder_siblings unless called_by_rebuild diff --git a/lib/closure_tree/numeric_deterministic_ordering.rb b/lib/closure_tree/numeric_deterministic_ordering.rb index 507e9622..12ddd45b 100644 --- a/lib/closure_tree/numeric_deterministic_ordering.rb +++ b/lib/closure_tree/numeric_deterministic_ordering.rb @@ -130,16 +130,9 @@ def append_child(child_node) end def prepend_child(child_node) - prior_parent_id = child_node._ct_parent_id child_node.order_value = -1 child_node.parent = self - child_node._ct_skip_sort_order_maintenance! if child_node.save - _ct_reorder_children - if prior_parent_id != _ct_id - scope_conditions = _ct.scope_values_from_instance(child_node) - _ct.reorder_with_parent_id(prior_parent_id, nil, scope_conditions) - end child_node.reload else child_node @@ -166,19 +159,11 @@ def add_sibling(sibling, add_after = true) _ct.with_advisory_lock do prior_sibling_parent = sibling.parent - reorder_from_value = if prior_sibling_parent == parent - [order_value, sibling.order_value].compact.min - else - order_value - end sibling.order_value = order_value sibling.parent = parent - sibling._ct_skip_sort_order_maintenance! sibling.save # may be a no-op - _ct_reorder_siblings(reorder_from_value) - # The sort order should be correct now except for self and sibling, which may need to flip: sibling_is_after = reload.order_value < sibling.reload.order_value if add_after != sibling_is_after diff --git a/test/closure_tree/label_order_value_test.rb b/test/closure_tree/label_order_value_test.rb index 6c472265..968a3323 100644 --- a/test/closure_tree/label_order_value_test.rb +++ b/test/closure_tree/label_order_value_test.rb @@ -53,66 +53,4 @@ def setup assert_equal 1, b.order_value assert_equal 2, c.order_value end - - test 'should reorder remaining root nodes when a root node becomes a child via prepend_child' do - node_1 = Label.create(name: 'node_1') - node_2 = Label.create(name: 'node_2') - node_3 = Label.create(name: 'node_3') - node_4 = Label.create(name: 'node_4') - - # Verify initial positions - assert_equal 0, node_1.order_value - assert_equal 1, node_2.order_value - assert_equal 2, node_3.order_value - assert_equal 3, node_4.order_value - - # Move node_2 as a child of node_3 using prepend_child - node_3.prepend_child(node_2) - - # Reload all nodes to get updated positions - node_1.reload - node_2.reload - node_3.reload - node_4.reload - - # Verify node_2 is now a child of node_3 with position 0 - assert_equal node_3.id, node_2.parent_id - assert_equal 0, node_2.order_value - - # Verify remaining root nodes have sequential positions without gaps - assert_equal 0, node_1.order_value - assert_equal 1, node_3.order_value - assert_equal 2, node_4.order_value - end - - test 'should reorder remaining root nodes when a root node becomes a child via append_child' do - node_1 = Label.create(name: 'node_1') - node_2 = Label.create(name: 'node_2') - node_3 = Label.create(name: 'node_3') - node_4 = Label.create(name: 'node_4') - - # Verify initial positions - assert_equal 0, node_1.order_value - assert_equal 1, node_2.order_value - assert_equal 2, node_3.order_value - assert_equal 3, node_4.order_value - - # Move node_2 as a child of node_3 using append_child - node_3.append_child(node_2) - - # Reload all nodes to get updated positions - node_1.reload - node_2.reload - node_3.reload - node_4.reload - - # Verify node_2 is now a child of node_3 with position 0 - assert_equal node_3.id, node_2.parent_id - assert_equal 0, node_2.order_value - - # Verify remaining root nodes have sequential positions without gaps - assert_equal 0, node_1.order_value - assert_equal 1, node_3.order_value - assert_equal 2, node_4.order_value - end end diff --git a/test/closure_tree/label_test.rb b/test/closure_tree/label_test.rb index 4bfab697..77a48f09 100644 --- a/test/closure_tree/label_test.rb +++ b/test/closure_tree/label_test.rb @@ -70,12 +70,12 @@ def create_preorder_tree(suffix = '') Label.roots.each_with_index do |root, root_idx| root.order_value = root_idx yield(root) if block_given? - root.save! + root.update_columns(root._ct.order_column => root_idx, type: root.type) root.self_and_descendants.each do |ea| - ea.children.to_a.sort_by(&:name).each_with_index do |ea, idx| - ea.order_value = idx - yield(ea) if block_given? - ea.save! + ea.children.to_a.sort_by(&:name).each_with_index do |child, idx| + child.order_value = idx + yield(child) if block_given? + child.update_columns(child._ct.order_column => idx, type: child.type) end end end diff --git a/test/closure_tree/multi_database_test.rb b/test/closure_tree/multi_database_test.rb index daf191ca..b005720e 100644 --- a/test/closure_tree/multi_database_test.rb +++ b/test/closure_tree/multi_database_test.rb @@ -9,6 +9,7 @@ def setup LiteRecord.connection.create_table :memory_tags, force: true do |t| t.string :name t.integer :parent_id + t.integer :sort_order t.timestamps end diff --git a/test/closure_tree/order_value_cross_database_test.rb b/test/closure_tree/order_value_cross_database_test.rb new file mode 100644 index 00000000..49192410 --- /dev/null +++ b/test/closure_tree/order_value_cross_database_test.rb @@ -0,0 +1,297 @@ +# frozen_string_literal: true + +require 'test_helper' + +class OrderValueCrossDatabaseTest < ActiveSupport::TestCase + # Setup for SQLite memory tables + def setup + super + # Create memory tables for SQLite with sort_order column + LiteRecord.connection.create_table :memory_tags, force: true do |t| + t.string :name + t.integer :parent_id + t.integer :sort_order + t.timestamps + end + + LiteRecord.connection.create_table :memory_tag_hierarchies, id: false, force: true do |t| + t.integer :ancestor_id, null: false + t.integer :descendant_id, null: false + t.integer :generations, null: false + end + + LiteRecord.connection.add_index :memory_tag_hierarchies, %i[ancestor_id descendant_id generations], + unique: true, name: 'memory_tag_anc_desc_idx' + LiteRecord.connection.add_index :memory_tag_hierarchies, [:descendant_id], name: 'memory_tag_desc_idx' + + # Clean up existing data + Label.delete_all + LabelHierarchy.delete_all + SecondaryTag.delete_all + SecondaryTagHierarchy.delete_all + end + + def teardown + LiteRecord.connection.drop_table :memory_tag_hierarchies, if_exists: true + LiteRecord.connection.drop_table :memory_tags, if_exists: true + super + end + + # =========================================== + # PostgreSQL (Label) Tests + # =========================================== + + test 'PostgreSQL: should reorder remaining root nodes when a root node becomes a child via prepend_child' do + node_1 = Label.create(name: 'node_1') + node_2 = Label.create(name: 'node_2') + node_3 = Label.create(name: 'node_3') + node_4 = Label.create(name: 'node_4') + + assert_equal 0, node_1.order_value + assert_equal 1, node_2.order_value + assert_equal 2, node_3.order_value + assert_equal 3, node_4.order_value + + node_3.prepend_child(node_2) + + node_1.reload + node_2.reload + node_3.reload + node_4.reload + + assert_equal node_3.id, node_2._ct_parent_id + assert_equal 0, node_2.order_value + + assert_equal 0, node_1.order_value + assert_equal 1, node_3.order_value + assert_equal 2, node_4.order_value + end + + test 'PostgreSQL: should reorder remaining root nodes when a root node becomes a child via append_child' do + node_1 = Label.create(name: 'node_1') + node_2 = Label.create(name: 'node_2') + node_3 = Label.create(name: 'node_3') + node_4 = Label.create(name: 'node_4') + + assert_equal 0, node_1.order_value + assert_equal 1, node_2.order_value + assert_equal 2, node_3.order_value + assert_equal 3, node_4.order_value + + node_3.append_child(node_2) + + node_1.reload + node_2.reload + node_3.reload + node_4.reload + + assert_equal node_3.id, node_2._ct_parent_id + assert_equal 0, node_2.order_value + + assert_equal 0, node_1.order_value + assert_equal 1, node_3.order_value + assert_equal 2, node_4.order_value + end + + test 'PostgreSQL: should reorder remaining root nodes when a root node becomes a child via add_sibling' do + node_1 = Label.create(name: 'node_1') + node_2 = Label.create(name: 'node_2') + node_3 = Label.create(name: 'node_3') + node_4 = Label.create(name: 'node_4') + + child = Label.create(name: 'child', parent: node_3) + + assert_equal 0, node_1.order_value + assert_equal 1, node_2.order_value + assert_equal 2, node_3.order_value + assert_equal 3, node_4.order_value + assert_equal 0, child.order_value + + child.add_sibling(node_2) + + node_1.reload + node_2.reload + node_3.reload + node_4.reload + + assert_equal node_3.id, node_2._ct_parent_id + + assert_equal 0, node_1.order_value + assert_equal 1, node_3.order_value + assert_equal 2, node_4.order_value + end + + # =========================================== + # SQLite (MemoryTag) Tests + # =========================================== + + test 'SQLite: should reorder remaining root nodes when a root node becomes a child via prepend_child' do + node_1 = MemoryTag.create(name: 'node_1') + node_2 = MemoryTag.create(name: 'node_2') + node_3 = MemoryTag.create(name: 'node_3') + node_4 = MemoryTag.create(name: 'node_4') + + assert_equal 0, node_1.order_value + assert_equal 1, node_2.order_value + assert_equal 2, node_3.order_value + assert_equal 3, node_4.order_value + + node_3.prepend_child(node_2) + + node_1.reload + node_2.reload + node_3.reload + node_4.reload + + assert_equal node_3.id, node_2._ct_parent_id + assert_equal 0, node_2.order_value + + assert_equal 0, node_1.order_value + assert_equal 1, node_3.order_value + assert_equal 2, node_4.order_value + end + + test 'SQLite: should reorder remaining root nodes when a root node becomes a child via append_child' do + node_1 = MemoryTag.create(name: 'node_1') + node_2 = MemoryTag.create(name: 'node_2') + node_3 = MemoryTag.create(name: 'node_3') + node_4 = MemoryTag.create(name: 'node_4') + + assert_equal 0, node_1.order_value + assert_equal 1, node_2.order_value + assert_equal 2, node_3.order_value + assert_equal 3, node_4.order_value + + node_3.append_child(node_2) + + node_1.reload + node_2.reload + node_3.reload + node_4.reload + + assert_equal node_3.id, node_2._ct_parent_id + assert_equal 0, node_2.order_value + + assert_equal 0, node_1.order_value + assert_equal 1, node_3.order_value + assert_equal 2, node_4.order_value + end + + test 'SQLite: should reorder remaining root nodes when a root node becomes a child via add_sibling' do + node_1 = MemoryTag.create(name: 'node_1') + node_2 = MemoryTag.create(name: 'node_2') + node_3 = MemoryTag.create(name: 'node_3') + node_4 = MemoryTag.create(name: 'node_4') + + child = MemoryTag.create(name: 'child', parent: node_3) + + assert_equal 0, node_1.order_value + assert_equal 1, node_2.order_value + assert_equal 2, node_3.order_value + assert_equal 3, node_4.order_value + assert_equal 0, child.order_value + + child.add_sibling(node_2) + + node_1.reload + node_2.reload + node_3.reload + node_4.reload + + assert_equal node_3.id, node_2._ct_parent_id + + assert_equal 0, node_1.order_value + assert_equal 1, node_3.order_value + assert_equal 2, node_4.order_value + end + + # =========================================== + # MySQL (SecondaryTag) Tests + # =========================================== + + test 'MySQL: should reorder remaining root nodes when a root node becomes a child via prepend_child' do + skip 'MySQL not configured' unless mysql?(SecondaryRecord.connection) + + node_1 = SecondaryTag.create(name: 'node_1') + node_2 = SecondaryTag.create(name: 'node_2') + node_3 = SecondaryTag.create(name: 'node_3') + node_4 = SecondaryTag.create(name: 'node_4') + + assert_equal 0, node_1.order_value + assert_equal 1, node_2.order_value + assert_equal 2, node_3.order_value + assert_equal 3, node_4.order_value + + node_3.prepend_child(node_2) + + node_1.reload + node_2.reload + node_3.reload + node_4.reload + + assert_equal node_3.id, node_2._ct_parent_id + assert_equal 0, node_2.order_value + + assert_equal 0, node_1.order_value + assert_equal 1, node_3.order_value + assert_equal 2, node_4.order_value + end + + test 'MySQL: should reorder remaining root nodes when a root node becomes a child via append_child' do + skip 'MySQL not configured' unless mysql?(SecondaryRecord.connection) + + node_1 = SecondaryTag.create(name: 'node_1') + node_2 = SecondaryTag.create(name: 'node_2') + node_3 = SecondaryTag.create(name: 'node_3') + node_4 = SecondaryTag.create(name: 'node_4') + + assert_equal 0, node_1.order_value + assert_equal 1, node_2.order_value + assert_equal 2, node_3.order_value + assert_equal 3, node_4.order_value + + node_3.append_child(node_2) + + node_1.reload + node_2.reload + node_3.reload + node_4.reload + + assert_equal node_3.id, node_2._ct_parent_id + assert_equal 0, node_2.order_value + + assert_equal 0, node_1.order_value + assert_equal 1, node_3.order_value + assert_equal 2, node_4.order_value + end + + test 'MySQL: should reorder remaining root nodes when a root node becomes a child via add_sibling' do + skip 'MySQL not configured' unless mysql?(SecondaryRecord.connection) + + node_1 = SecondaryTag.create(name: 'node_1') + node_2 = SecondaryTag.create(name: 'node_2') + node_3 = SecondaryTag.create(name: 'node_3') + node_4 = SecondaryTag.create(name: 'node_4') + + child = SecondaryTag.create(name: 'child', parent: node_3) + + assert_equal 0, node_1.order_value + assert_equal 1, node_2.order_value + assert_equal 2, node_3.order_value + assert_equal 3, node_4.order_value + assert_equal 0, child.order_value + + child.add_sibling(node_2) + + node_1.reload + node_2.reload + node_3.reload + node_4.reload + + assert_equal node_3.id, node_2._ct_parent_id + + assert_equal 0, node_1.order_value + assert_equal 1, node_3.order_value + assert_equal 2, node_4.order_value + end +end diff --git a/test/dummy/app/models/memory_tag.rb b/test/dummy/app/models/memory_tag.rb index 8863898b..22b2a334 100644 --- a/test/dummy/app/models/memory_tag.rb +++ b/test/dummy/app/models/memory_tag.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class MemoryTag < LiteRecord - has_closure_tree + has_closure_tree order: :sort_order, numeric_order: true end diff --git a/test/dummy/app/models/secondary_tag.rb b/test/dummy/app/models/secondary_tag.rb index 1cd9eece..e565144f 100644 --- a/test/dummy/app/models/secondary_tag.rb +++ b/test/dummy/app/models/secondary_tag.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class SecondaryTag < SecondaryRecord - has_closure_tree + has_closure_tree order: :sort_order, numeric_order: true end diff --git a/test/dummy/db/secondary_schema.rb b/test/dummy/db/secondary_schema.rb index f35d629f..1267df18 100644 --- a/test/dummy/db/secondary_schema.rb +++ b/test/dummy/db/secondary_schema.rb @@ -16,6 +16,7 @@ create_table 'secondary_tags', force: true do |t| t.string 'name' t.integer 'parent_id' + t.integer 'sort_order' t.datetime 'created_at' t.datetime 'updated_at' end From df0f483f3e2095f7c2ee638759ae3fa58cf9943d Mon Sep 17 00:00:00 2001 From: Sampat Badhe Date: Sat, 14 Feb 2026 14:21:12 +0530 Subject: [PATCH 3/6] Handle scope changes when reordering siblings Detect and handle changes to scoped attributes during saves so siblings are reordered correctly when an object changes scope. _ct_after_save now checks for scope changes and triggers reordering of the prior and current siblings. _ct_reorder_prior_siblings_if_parent_changed was updated to consider scope changes (and early-return for new records), use the previous scope values, and only act when the parent or scope actually changed. Added support methods previous_scope_values_from_instance and scope_changed? to build previous-scope conditions and detect scope changes for Symbol or Array scope configurations. Tests were added to verify reordering behavior when moving items between scopes and with multiple scope attributes. --- lib/closure_tree/hierarchy_maintenance.rb | 6 ++ .../numeric_deterministic_ordering.rb | 11 ++- lib/closure_tree/support.rb | 39 ++++++++ test/closure_tree/scope_test.rb | 97 +++++++++++++++++++ 4 files changed, 150 insertions(+), 3 deletions(-) diff --git a/lib/closure_tree/hierarchy_maintenance.rb b/lib/closure_tree/hierarchy_maintenance.rb index 843bf51c..a5b1b1d9 100644 --- a/lib/closure_tree/hierarchy_maintenance.rb +++ b/lib/closure_tree/hierarchy_maintenance.rb @@ -34,8 +34,14 @@ def _ct_before_save end def _ct_after_save + scope_changed = _ct.order_is_numeric? && _ct.scope_changed?(self) + if saved_changes[_ct.parent_column_name] || @was_new_record rebuild! + elsif scope_changed + # Scope changed without parent change - reorder old scope's siblings + _ct_reorder_prior_siblings_if_parent_changed + _ct_reorder_siblings elsif saved_changes[_ct.order_column_sym] _ct_reorder_siblings(saved_changes[_ct.order_column_sym].min) end diff --git a/lib/closure_tree/numeric_deterministic_ordering.rb b/lib/closure_tree/numeric_deterministic_ordering.rb index 12ddd45b..e0e1644f 100644 --- a/lib/closure_tree/numeric_deterministic_ordering.rb +++ b/lib/closure_tree/numeric_deterministic_ordering.rb @@ -12,11 +12,16 @@ module NumericDeterministicOrdering end def _ct_reorder_prior_siblings_if_parent_changed - return unless saved_change_to_attribute?(_ct.parent_column_name) && !@was_new_record + return if @was_new_record + + parent_changed = saved_change_to_attribute?(_ct.parent_column_name) + scope_changed = _ct.scope_changed?(self) + + return unless parent_changed || scope_changed was_parent_id = attribute_before_last_save(_ct.parent_column_name) - scope_conditions = _ct.scope_values_from_instance(self) - _ct.reorder_with_parent_id(was_parent_id, nil, scope_conditions) + previous_scope_conditions = _ct.previous_scope_values_from_instance(self) + _ct.reorder_with_parent_id(was_parent_id, nil, previous_scope_conditions) end def _ct_reorder_siblings(minimum_sort_order_value = nil) diff --git a/lib/closure_tree/support.rb b/lib/closure_tree/support.rb index f26ab056..b8fa91d2 100644 --- a/lib/closure_tree/support.rb +++ b/lib/closure_tree/support.rb @@ -257,6 +257,45 @@ def scope_values_from_instance(instance) scope_hash end + def previous_scope_values_from_instance(instance) + return {} unless options[:scope] && instance + + scope_option = options[:scope] + scope_hash = {} + + case scope_option + when Symbol + value = instance.attribute_before_last_save(scope_option) + scope_hash[scope_option] = value + when Array + scope_option.each do |item| + if item.is_a?(Symbol) + value = instance.attribute_before_last_save(item) + scope_hash[item] = value + end + end + end + + scope_hash + end + + def scope_changed?(instance) + return false unless options[:scope] && instance + + scope_option = options[:scope] + + case scope_option + when Symbol + instance.saved_change_to_attribute?(scope_option) + when Array + scope_option.any? do |item| + item.is_a?(Symbol) && instance.saved_change_to_attribute?(item) + end + else + false + end + end + def apply_scope_conditions(scope, instance = nil) return scope unless options[:scope] && instance diff --git a/test/closure_tree/scope_test.rb b/test/closure_tree/scope_test.rb index aa0e141b..2884fec2 100644 --- a/test/closure_tree/scope_test.rb +++ b/test/closure_tree/scope_test.rb @@ -234,4 +234,101 @@ def test_build_scope_where_clause_with_nil_value_sqlite assert_includes clause, 'IS NULL' assert_includes clause, '456' end + + def test_reorder_previous_scope_siblings_when_scope_changes + # Create items in scope user_id: 1 + item1 = ScopedItem.create!(name: 'item1', user_id: 1) + item2 = ScopedItem.create!(name: 'item2', user_id: 1) + item3 = ScopedItem.create!(name: 'item3', user_id: 1) + + # Verify initial order values + assert_equal 0, item1.order_value + assert_equal 1, item2.order_value + assert_equal 2, item3.order_value + + # Move item2 to a different scope (user_id: 2) + item2.update!(user_id: 2) + + # Reload all items + item1.reload + item2.reload + item3.reload + + # item2 should be first in its new scope + assert_equal 0, item2.order_value + + # item1 and item3 should be reordered without gaps in old scope + assert_equal 0, item1.order_value + assert_equal 1, item3.order_value + end + + def test_reorder_previous_scope_siblings_when_multiple_scope_changes + # Create items in scope user_id: 1, group_id: 10 + item1 = MultiScopedItem.create!(name: 'item1', user_id: 1, group_id: 10) + item2 = MultiScopedItem.create!(name: 'item2', user_id: 1, group_id: 10) + item3 = MultiScopedItem.create!(name: 'item3', user_id: 1, group_id: 10) + + # Verify initial order values + assert_equal 0, item1.order_value + assert_equal 1, item2.order_value + assert_equal 2, item3.order_value + + # Move item2 to a different scope (user_id: 2, group_id: 10) + item2.update!(user_id: 2) + + # Reload all items + item1.reload + item2.reload + item3.reload + + # item2 should be first in its new scope + assert_equal 0, item2.order_value + + # item1 and item3 should be reordered without gaps in old scope + assert_equal 0, item1.order_value + assert_equal 1, item3.order_value + end + + def test_scope_changed_detection + # Test scope_changed? by checking the actual reordering behavior + # which implicitly tests that scope_changed? works during callbacks + item1 = ScopedItem.create!(name: 'item1', user_id: 1) + item2 = ScopedItem.create!(name: 'item2', user_id: 1) + + assert_equal 0, item1.order_value + assert_equal 1, item2.order_value + + # Change scope - if scope_changed? works, item1 will be reordered in new scope + item2.update!(user_id: 2) + + item1.reload + item2.reload + + # item2 should be 0 in new scope (proves scope change was detected) + assert_equal 0, item2.order_value + # item1 should remain 0 (was already 0, reordering removes gap from item2) + assert_equal 0, item1.order_value + end + + def test_previous_scope_values_from_instance + # Test previous_scope_values by checking that OLD scope siblings are reordered + # which implicitly tests that previous scope values are correctly retrieved + item1 = ScopedItem.create!(name: 'item1', user_id: 1) + item2 = ScopedItem.create!(name: 'item2', user_id: 1) + item3 = ScopedItem.create!(name: 'item3', user_id: 1) + + assert_equal 0, item1.order_value + assert_equal 1, item2.order_value + assert_equal 2, item3.order_value + + # Move middle item to different scope + item2.update!(user_id: 2) + + item1.reload + item3.reload + + # If previous_scope_values works, item3 should now be 1 (gap filled) + assert_equal 0, item1.order_value + assert_equal 1, item3.order_value + end end From 8398c5ac60020ab74cf4ed09beafb0dda6d6bfe1 Mon Sep 17 00:00:00 2001 From: Sampat Badhe Date: Mon, 16 Feb 2026 14:07:38 +0530 Subject: [PATCH 4/6] Deprecate _ct_skip_sort_order_maintenance! --- lib/closure_tree/hierarchy_maintenance.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/closure_tree/hierarchy_maintenance.rb b/lib/closure_tree/hierarchy_maintenance.rb index a5b1b1d9..5bda0660 100644 --- a/lib/closure_tree/hierarchy_maintenance.rb +++ b/lib/closure_tree/hierarchy_maintenance.rb @@ -17,6 +17,13 @@ def _ct_skip_cycle_detection! @_ct_skip_cycle_detection = true end + def _ct_skip_sort_order_maintenance! + ActiveSupport::Deprecation.new.warn( + '_ct_skip_sort_order_maintenance! is deprecated and will be removed in the next major version. ' \ + 'Sort order maintenance is now handled automatically.' + ) + end + def _ct_validate if !(defined? @_ct_skip_cycle_detection) && !new_record? && # don't validate for cycles if we're a new record From 3b1c2cf3934ee8b3a1189674fb5783e0ffc8b586 Mon Sep 17 00:00:00 2001 From: Sampat Badhe Date: Mon, 16 Feb 2026 15:41:53 +0530 Subject: [PATCH 5/6] Reorder siblings only when ordering enabled Add a guard to check _ct.order_option? before inspecting saved_changes for the order column. This prevents attempting to read order-column changes when ordering is not enabled, avoiding unintended reordering or errors when the order column is absent. --- lib/closure_tree/hierarchy_maintenance.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/closure_tree/hierarchy_maintenance.rb b/lib/closure_tree/hierarchy_maintenance.rb index 5bda0660..ffb376bb 100644 --- a/lib/closure_tree/hierarchy_maintenance.rb +++ b/lib/closure_tree/hierarchy_maintenance.rb @@ -49,7 +49,7 @@ def _ct_after_save # Scope changed without parent change - reorder old scope's siblings _ct_reorder_prior_siblings_if_parent_changed _ct_reorder_siblings - elsif saved_changes[_ct.order_column_sym] + elsif _ct.order_option? && saved_changes[_ct.order_column_sym] _ct_reorder_siblings(saved_changes[_ct.order_column_sym].min) end if saved_changes[_ct.parent_column_name] && !@was_new_record From 7e14a467c3ca18d719132090ac01d04adb10c95e Mon Sep 17 00:00:00 2001 From: Sampat Badhe Date: Mon, 16 Feb 2026 17:44:45 +0530 Subject: [PATCH 6/6] Move SQLite memory tables to schema Remove per-test setup/teardown that created/dropped in-memory memory_tags and memory_tag_hierarchies tables and instead add those tables and indexes to test/dummy/db/sqlite_schema.rb. Update test/dummy/lib/tasks/db.rake to explicitly establish the :lite connection and load the SQLite schema so the in-memory tables are created centrally for tests. This centralizes SQLite test DB setup and simplifies the test files. --- test/closure_tree/multi_database_test.rb | 27 ------------------- .../order_value_cross_database_test.rb | 26 ------------------ test/dummy/db/sqlite_schema.rb | 18 +++++++++++++ test/dummy/lib/tasks/db.rake | 7 ++++- 4 files changed, 24 insertions(+), 54 deletions(-) diff --git a/test/closure_tree/multi_database_test.rb b/test/closure_tree/multi_database_test.rb index b005720e..fa4d3e1a 100644 --- a/test/closure_tree/multi_database_test.rb +++ b/test/closure_tree/multi_database_test.rb @@ -3,33 +3,6 @@ require 'test_helper' class MultiDatabaseTest < ActiveSupport::TestCase - def setup - super - # Create memory tables - always recreate for clean state - LiteRecord.connection.create_table :memory_tags, force: true do |t| - t.string :name - t.integer :parent_id - t.integer :sort_order - t.timestamps - end - - LiteRecord.connection.create_table :memory_tag_hierarchies, id: false, force: true do |t| - t.integer :ancestor_id, null: false - t.integer :descendant_id, null: false - t.integer :generations, null: false - end - - LiteRecord.connection.add_index :memory_tag_hierarchies, %i[ancestor_id descendant_id generations], - unique: true, name: 'memory_tag_anc_desc_idx' - LiteRecord.connection.add_index :memory_tag_hierarchies, [:descendant_id], name: 'memory_tag_desc_idx' - end - - def teardown - # Clean up SQLite tables after each test - LiteRecord.connection.drop_table :memory_tag_hierarchies, if_exists: true - LiteRecord.connection.drop_table :memory_tags, if_exists: true - super - end def test_postgresql_with_advisory_lock skip 'PostgreSQL not configured' unless postgresql?(ApplicationRecord.connection) diff --git a/test/closure_tree/order_value_cross_database_test.rb b/test/closure_tree/order_value_cross_database_test.rb index 49192410..f10e4917 100644 --- a/test/closure_tree/order_value_cross_database_test.rb +++ b/test/closure_tree/order_value_cross_database_test.rb @@ -3,40 +3,14 @@ require 'test_helper' class OrderValueCrossDatabaseTest < ActiveSupport::TestCase - # Setup for SQLite memory tables def setup super - # Create memory tables for SQLite with sort_order column - LiteRecord.connection.create_table :memory_tags, force: true do |t| - t.string :name - t.integer :parent_id - t.integer :sort_order - t.timestamps - end - - LiteRecord.connection.create_table :memory_tag_hierarchies, id: false, force: true do |t| - t.integer :ancestor_id, null: false - t.integer :descendant_id, null: false - t.integer :generations, null: false - end - - LiteRecord.connection.add_index :memory_tag_hierarchies, %i[ancestor_id descendant_id generations], - unique: true, name: 'memory_tag_anc_desc_idx' - LiteRecord.connection.add_index :memory_tag_hierarchies, [:descendant_id], name: 'memory_tag_desc_idx' - - # Clean up existing data Label.delete_all LabelHierarchy.delete_all SecondaryTag.delete_all SecondaryTagHierarchy.delete_all end - def teardown - LiteRecord.connection.drop_table :memory_tag_hierarchies, if_exists: true - LiteRecord.connection.drop_table :memory_tags, if_exists: true - super - end - # =========================================== # PostgreSQL (Label) Tests # =========================================== diff --git a/test/dummy/db/sqlite_schema.rb b/test/dummy/db/sqlite_schema.rb index 30e0fd0e..93b1563d 100644 --- a/test/dummy/db/sqlite_schema.rb +++ b/test/dummy/db/sqlite_schema.rb @@ -46,4 +46,22 @@ add_index 'memory_adoptable_tag_hierarchies', %i[ancestor_id descendant_id generations], unique: true, name: 'memory_adoptable_tag_anc_desc_idx' add_index 'memory_adoptable_tag_hierarchies', [:descendant_id], name: 'memory_adoptable_tag_desc_idx' + + create_table 'memory_tags', force: true do |t| + t.string 'name' + t.integer 'parent_id' + t.integer 'sort_order' + t.datetime 'created_at' + t.datetime 'updated_at' + end + + create_table 'memory_tag_hierarchies', id: false, force: true do |t| + t.integer 'ancestor_id', null: false + t.integer 'descendant_id', null: false + t.integer 'generations', null: false + end + + add_index 'memory_tag_hierarchies', %i[ancestor_id descendant_id generations], unique: true, + name: 'memory_tag_anc_desc_idx' + add_index 'memory_tag_hierarchies', [:descendant_id], name: 'memory_tag_desc_idx' end diff --git a/test/dummy/lib/tasks/db.rake b/test/dummy/lib/tasks/db.rake index 368e5db1..62df8a4e 100644 --- a/test/dummy/lib/tasks/db.rake +++ b/test/dummy/lib/tasks/db.rake @@ -17,7 +17,12 @@ namespace :db do load Rails.root.join('db/secondary_schema.rb') end - # SQLite is in-memory so it will be created automatically + # Load SQLite (lite) database schema + ActiveRecord::Base.establish_connection(:lite) + ActiveRecord::Base.connection.disconnect! if ActiveRecord::Base.connection.active? + ActiveRecord::Base.establish_connection(:lite) + load Rails.root.join('db/sqlite_schema.rb') + ActiveRecord::Base.establish_connection(:primary) end