From e45cf1f1608fb740e354b12bdfcc1bfe1b30471e Mon Sep 17 00:00:00 2001 From: Oliver Morgan Date: Fri, 26 Dec 2025 19:52:53 +0000 Subject: [PATCH 1/7] initial commit --- README.md | 29 ++++++++++ lib/positioning.rb | 6 +- lib/positioning/healer.rb | 68 +++++++++++++++++------ test/test_repositioning.rb | 110 +++++++++++++++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 18 deletions(-) create mode 100644 test/test_repositioning.rb diff --git a/README.md b/README.md index b3ebd27..7dbcfb9 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,13 @@ If you have a polymorphic `belongs_to` then you'll want to add the type column t `add_index :items, [:listable_id, :listable_type, :position], unique: true` +If you are using Rails 7.1+ you can use `add_unique_key` instead and defer the constraint like so: + +```ruby +add_unique_key :items, [:list_id, :position], deferrable: :deferred +add_unique_key :items, [:listable_id, :listable_type, :position], deferrable: :deferred +``` + The Positioning gem uses `0` and negative integers to rearrange the lists it manages so don't add database validations to restrict the usage of these. You are also restricted from using `0` and negative integers as position values. If you try, the position value will become `1`. If you try to set an explicit position value that is greater than the next available list position, it will be rounded down to that value. ### Declaring Positioning @@ -189,6 +196,28 @@ other_item.id # => 11 item.update position: {after: 11} ``` +#### Repositioning in Bulk + +If you need to reorder a list in one go, you can use the bulk reposition helper that is added for each positioned column. The method name is based on the column, so the default is `update_position_in_order_of!`. + +You can pass an array of ids in the desired order: + +```ruby +Item.update_position_in_order_of!([3, 2, 1]) +``` + +Or a hash of ids to weights, where lower weights come first (ties preserve hash order): + +```ruby +Item.update_position_in_order_of!({ 3 => 0, 2 => 1, 1 => 2 }) +``` + +If you have multiple positioned columns, the method name changes accordingly: + +```ruby +CategorisedItem.update_category_position_in_order_of!([3, 2, 1]) +``` + ##### Duplicating (`dup`) When you call `dup` on an instance in the list, all position columns on the duplicate will be set to `nil` so that when this duplicate is saved it will be added either to the end of the current scopes (if unchanged) or to the end of any new scopes. Of course you can then override the position of the duplicate before you save it if necessary. diff --git a/lib/positioning.rb b/lib/positioning.rb index 638de7d..6f50d85 100644 --- a/lib/positioning.rb +++ b/lib/positioning.rb @@ -56,7 +56,11 @@ def positioned(on: [], column: :position) before_destroy { Mechanisms.new(self, column).destroy_position } define_singleton_method(:"heal_#{column}_column!") do |order = column| - Healer.new(self, column, order).heal + Healer.new(self, column).heal(order) + end + + define_singleton_method(:"update_#{column}_in_order_of!") do |ids| + Healer.new(self, column).reposition(ids) end end end diff --git a/lib/positioning/healer.rb b/lib/positioning/healer.rb index 42d90fb..4452af0 100644 --- a/lib/positioning/healer.rb +++ b/lib/positioning/healer.rb @@ -1,35 +1,69 @@ module Positioning class Healer - def initialize(model, column, order) + def initialize(model, column) @model = model @column = column.to_sym - @order = order end - def heal - if scope_columns.present? - @model.unscope(:order).reselect(*scope_columns).distinct.each do |scope_record| - @model.transaction do - if scope_associations.present? - scope_associations.each do |scope_association| - scope_record.send(scope_association)&.lock! - end + def heal(order) + each_scope do |scope| + sequence scope.reorder(order) + end + end + + def reposition(values) + pk_type = @model.type_for_attribute(@model.primary_key) + ids = if values.is_a?(Hash) + pairs = values.to_a.reject { |id, weight| id.nil? || weight.nil? } + pairs = pairs.each_with_index.map { |(id, weight), index| [id, weight, index] } + pairs.sort_by! { |(_, weight, index)| [weight, index] } + pairs.map { |id, _weight, _index| id } else - @model.where(scope_record.slice(*scope_columns)).lock! - end + Array.wrap(values) + end.map { |id| pk_type.cast(id) }.compact_blank.uniq + + return if ids.empty? + + each_scope(@model.primary_key => ids) do |scope| + sequence [ + *scope.where(@model.primary_key => ids).unscope(:order).find(ids & scope.ids), + *scope.where.not(@model.primary_key => ids).order(@column) + ] + end + end - sequence @model.where(scope_record.slice(*scope_columns)) + private + + def each_scope(conditions = {}) + if scope_columns.present? + @model.where(conditions).unscope(:order).reselect(*scope_columns).distinct.each do |scope_record| + @model.transaction do + lock_scope(scope_record) + scope = @model.where(scope_record.slice(*scope_columns)) + last_position = (scope.maximum(@column) || 0) + 1 + scope.update_all(@column => @model.arel_table[@column] - last_position) + yield @model.where(scope_record.slice(*scope_columns)).unscope(:select) end end else @model.transaction do @model.all.lock! - sequence @model + last_position = (@model.maximum(@column) || 0) + 1 + @model.update_all(@column => @model.arel_table[@column] - last_position) + yield @model.unscope(:select) end end end - private + def lock_scope(scope_record) + if scope_associations.present? + scope_associations.each do |scope_association| + scope_record.send(scope_association)&.lock! + end + else + @model.where(scope_record.slice(*scope_columns)).lock! + end + end def scope_columns @model.positioning_columns[@column][:scope_columns] @@ -39,8 +73,8 @@ def scope_associations @model.positioning_columns[@column][:scope_associations] end - def sequence(scope) - scope.unscope(:select).reorder(@order).each.with_index(1) do |record, index| + def sequence(records) + records.each.with_index(1) do |record, index| record.update_columns @column => index end end diff --git a/test/test_repositioning.rb b/test/test_repositioning.rb new file mode 100644 index 0000000..e80047f --- /dev/null +++ b/test/test_repositioning.rb @@ -0,0 +1,110 @@ +require "test_helper" + +class TestRepositioning < Minitest::Test + include Minitest::Hooks + + def around + ActiveRecord::Base.transaction do + super + raise ActiveRecord::Rollback + end + end + + def test_reposition_position + first_list = List.create name: "First List" + second_list = List.create name: "Second List" + + first_item = first_list.new_items.create name: "First Item" + second_item = first_list.new_items.create name: "Second Item" + third_item = first_list.new_items.create name: "Third Item" + + fourth_item = second_list.new_items.create name: "Fourth Item" + fifth_item = second_list.new_items.create name: "Fifth Item" + sixth_item = second_list.new_items.create name: "Sixth Item" + + NewItem.update_position_in_order_of!([ + third_item.id, + second_item.id, + first_item.id, + sixth_item.id, + fifth_item.id, + fourth_item.id + ]) + + assert_equal [1, 2, 3], [third_item.reload, second_item.reload, first_item.reload].map(&:position) + assert_equal [1, 2, 3], [sixth_item.reload, fifth_item.reload, fourth_item.reload].map(&:position) + end + + def test_reposition_position_with_weights + first_product = Product.create name: "First Product" + second_product = Product.create name: "Second Product" + third_product = Product.create name: "Third Product" + + Product.update_position_in_order_of!( + third_product.id => 0, + second_product.id => 1, + first_product.id => 2 + ) + + assert_equal [1, 2, 3], [third_product.reload, second_product.reload, first_product.reload].map(&:position) + end + + def test_reposition_position_with_composite_primary_key + list = List.create name: "Composite List" + + first_item = list.composite_primary_key_items.create item_id: 1, account_id: 10, name: "First Item" + second_item = list.composite_primary_key_items.create item_id: 2, account_id: 10, name: "Second Item" + third_item = list.composite_primary_key_items.create item_id: 3, account_id: 10, name: "Third Item" + + CompositePrimaryKeyItem.update_position_in_order_of!([ + third_item.id, + second_item.id, + first_item.id + ]) + + assert_equal [1, 2, 3], [third_item.reload, second_item.reload, first_item.reload].map(&:position) + end + + def test_reposition_position_on_a_tree + first_category = Category.create name: "First Category" + second_category = Category.create name: "Second Category" + third_category = Category.create name: "Third Category", parent: first_category + fourth_category = Category.create name: "Fourth Category", parent: second_category + fifth_category = Category.create name: "Fifth Category", parent: second_category + sixth_category = Category.create name: "Sixth Category", parent: second_category + + Category.update_position_in_order_of!([ + second_category.id, + first_category.id, + third_category.id, + fifth_category.id, + fourth_category.id, + sixth_category.id + ]) + + assert_equal [1, 2, 1], [second_category.reload, first_category.reload, third_category.reload].map(&:position) + assert_equal [1, 2, 3], [fifth_category.reload, fourth_category.reload, sixth_category.reload].map(&:position) + end + + def test_reposition_position_with_no_scope + first_product = Product.create name: "First Product" + second_product = Product.create name: "Second Product" + third_product = Product.create name: "Third Product" + + Product.update_position_in_order_of!([third_product.id, second_product.id, first_product.id]) + + assert_equal [1, 2, 3], [third_product.reload, second_product.reload, first_product.reload].map(&:position) + end + + def test_reposition_position_with_default_scope + first_list = List.create name: "First List" + + first_item = first_list.default_scope_items.create name: "First Item" + second_item = first_list.default_scope_items.create name: "Second Item" + third_item = first_list.default_scope_items.create name: "Third Item" + + DefaultScopeItem.update_position_in_order_of!([second_item.id, third_item.id, first_item.id]) + + assert_equal [1, 2, 3], [second_item.reload, third_item.reload, first_item.reload].map(&:position) + end +end From 29a80709e0a1b9c41b0aa2cc4ee46b6052a420f9 Mon Sep 17 00:00:00 2001 From: Oliver Morgan Date: Sun, 28 Dec 2025 14:47:52 +0000 Subject: [PATCH 2/7] use reorder --- lib/positioning/healer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/positioning/healer.rb b/lib/positioning/healer.rb index 4452af0..f8e2019 100644 --- a/lib/positioning/healer.rb +++ b/lib/positioning/healer.rb @@ -27,7 +27,7 @@ def reposition(values) each_scope(@model.primary_key => ids) do |scope| sequence [ *scope.where(@model.primary_key => ids).unscope(:order).find(ids & scope.ids), - *scope.where.not(@model.primary_key => ids).order(@column) + *scope.where.not(@model.primary_key => ids).reorder(@column) ] end end From 1530c04b2d3a2d114ab320e9ea203d43665c43d1 Mon Sep 17 00:00:00 2001 From: Oliver Morgan Date: Sun, 28 Dec 2025 16:26:14 +0000 Subject: [PATCH 3/7] implement partial repositioning --- README.md | 8 ++++++++ lib/positioning/healer.rb | 39 ++++++++++++++++++++------------------ test/test_repositioning.rb | 17 +++++++++++++++++ 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 7dbcfb9..f76ae73 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,14 @@ Or a hash of ids to weights, where lower weights come first (ties preserve hash Item.update_position_in_order_of!({ 3 => 0, 2 => 1, 1 => 2 }) ``` +If you pass a partial list, only those records move and they reuse the positions already held by the selected records. Unselected records keep their positions: + +```ruby +# positions: A:1, B:2, C:3, D:4, E:5 +Item.update_position_in_order_of!([4, 2]) +# positions: A:1, D:2, C:3, B:4, E:5 +``` + If you have multiple positioned columns, the method name changes accordingly: ```ruby diff --git a/lib/positioning/healer.rb b/lib/positioning/healer.rb index f8e2019..e571c2b 100644 --- a/lib/positioning/healer.rb +++ b/lib/positioning/healer.rb @@ -7,7 +7,14 @@ def initialize(model, column) def heal(order) each_scope do |scope| - sequence scope.reorder(order) + + # Move whole scope out of the way + last_position = (scope.maximum(@column) || 0) + 1 + scope.update_all(@column => @model.arel_table[@column] - last_position) + + scope.order(order).each.with_index(1) do |record, index| + record.update_column @column, index + end end end @@ -25,10 +32,17 @@ def reposition(values) return if ids.empty? each_scope(@model.primary_key => ids) do |scope| - sequence [ - *scope.where(@model.primary_key => ids).unscope(:order).find(ids & scope.ids), - *scope.where.not(@model.primary_key => ids).reorder(@column) - ] + scoped_records = scope.where(@model.primary_key => ids) + scoped_ids = ids & scoped_records.ids + positions = scoped_records.pluck(@column).sort + + # Move only selected scope out of the way + last_position = (positions.max || 0) + 1 + scoped_records.update_all(@column => @model.arel_table[@column] - last_position) + + scoped_records.find(scoped_ids).zip(positions).each do |record, position| + record.update_column @column, position + end end end @@ -39,18 +53,13 @@ def each_scope(conditions = {}) @model.where(conditions).unscope(:order).reselect(*scope_columns).distinct.each do |scope_record| @model.transaction do lock_scope(scope_record) - scope = @model.where(scope_record.slice(*scope_columns)) - last_position = (scope.maximum(@column) || 0) + 1 - scope.update_all(@column => @model.arel_table[@column] - last_position) - yield @model.where(scope_record.slice(*scope_columns)).unscope(:select) + yield @model.where(scope_record.slice(*scope_columns)).unscope(:order, :select) end end else @model.transaction do @model.all.lock! - last_position = (@model.maximum(@column) || 0) + 1 - @model.update_all(@column => @model.arel_table[@column] - last_position) - yield @model.unscope(:select) + yield @model.unscope(:order, :select) end end end @@ -72,11 +81,5 @@ def scope_columns def scope_associations @model.positioning_columns[@column][:scope_associations] end - - def sequence(records) - records.each.with_index(1) do |record, index| - record.update_columns @column => index - end - end end end diff --git a/test/test_repositioning.rb b/test/test_repositioning.rb index e80047f..2ad1f32 100644 --- a/test/test_repositioning.rb +++ b/test/test_repositioning.rb @@ -35,6 +35,23 @@ def test_reposition_position assert_equal [1, 2, 3], [sixth_item.reload, fifth_item.reload, fourth_item.reload].map(&:position) end + def test_reposition_position_with_partial_selection + list = List.create name: "Partial List" + + first_item = list.new_items.create name: "First Item" + second_item = list.new_items.create name: "Second Item" + third_item = list.new_items.create name: "Third Item" + fourth_item = list.new_items.create name: "Fourth Item" + fifth_item = list.new_items.create name: "Fifth Item" + + NewItem.update_position_in_order_of!([ + fourth_item.id, + second_item.id + ]) + + assert_equal [1, 2, 3, 4, 5], [first_item.reload, fourth_item.reload, third_item.reload, second_item.reload, fifth_item.reload].map(&:position) + end + def test_reposition_position_with_weights first_product = Product.create name: "First Product" second_product = Product.create name: "Second Product" From 264962c5a25dc455682c5d95dd539649857950d5 Mon Sep 17 00:00:00 2001 From: Brendon Muir Date: Mon, 5 Jan 2026 14:40:59 +1300 Subject: [PATCH 4/7] Fix Ruby Standard errors --- lib/positioning/healer.rb | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/positioning/healer.rb b/lib/positioning/healer.rb index e571c2b..7cbb695 100644 --- a/lib/positioning/healer.rb +++ b/lib/positioning/healer.rb @@ -7,7 +7,6 @@ def initialize(model, column) def heal(order) each_scope do |scope| - # Move whole scope out of the way last_position = (scope.maximum(@column) || 0) + 1 scope.update_all(@column => @model.arel_table[@column] - last_position) @@ -21,13 +20,13 @@ def heal(order) def reposition(values) pk_type = @model.type_for_attribute(@model.primary_key) ids = if values.is_a?(Hash) - pairs = values.to_a.reject { |id, weight| id.nil? || weight.nil? } - pairs = pairs.each_with_index.map { |(id, weight), index| [id, weight, index] } - pairs.sort_by! { |(_, weight, index)| [weight, index] } - pairs.map { |id, _weight, _index| id } - else - Array.wrap(values) - end.map { |id| pk_type.cast(id) }.compact_blank.uniq + pairs = values.to_a.reject { |id, weight| id.nil? || weight.nil? } + pairs = pairs.each_with_index.map { |(id, weight), index| [id, weight, index] } + pairs.sort_by! { |(_, weight, index)| [weight, index] } + pairs.map { |id, _weight, _index| id } + else + Array.wrap(values) + end.map { |id| pk_type.cast(id) }.compact_blank.uniq return if ids.empty? From 120dba464a947937223b0e71c7600f7cedfb3add Mon Sep 17 00:00:00 2001 From: Brendon Muir Date: Mon, 5 Jan 2026 15:06:46 +1300 Subject: [PATCH 5/7] Fix mysql2 gem version to 0.5.6 as 0.5.7 has a bug. Update other dependencies at the same time. --- Gemfile | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Gemfile b/Gemfile index 6d4fa57..2a3e35d 100644 --- a/Gemfile +++ b/Gemfile @@ -3,13 +3,13 @@ source "https://rubygems.org" # Specify your gem's dependencies in positioning.gemspec gemspec -gem "rake", "~> 13.0" +gem "rake", "~> 13.3" -gem "minitest", "~> 5.0" -gem "minitest-hooks", "~> 1.5.1" -gem "mocha", "~> 2.1.0" +gem "minitest", "~> 6.0" +gem "minitest-hooks", "~> 1.5.3" +gem "mocha", "~> 3.0.1" -gem "standard", "~> 1.3" +gem "standard", "~> 1.52.0" if ENV["RAILS_VERSION"] gem "activerecord", ENV["RAILS_VERSION"] @@ -20,12 +20,12 @@ case ENV["DB"] when "sqlite" if ENV["RAILS_VERSION"] && Gem::Version.new(ENV["RAILS_VERSION"]) >= Gem::Version.new("7.2") - gem "sqlite3", "~> 2.2.0" + gem "sqlite3", "~> 2.9.0" else - gem "sqlite3", "~> 1.7.2" + gem "sqlite3", "~> 1.7.3" end when "postgresql" - gem "pg", "~> 1.5.5" + gem "pg", "~> 1.6.3" else - gem "mysql2", "~> 0.5.6" + gem "mysql2", "0.5.6" end From 8aeb792d43142be9303b034855ef24cf6d14aef8 Mon Sep 17 00:00:00 2001 From: Brendon Muir Date: Mon, 5 Jan 2026 15:13:44 +1300 Subject: [PATCH 6/7] Modernise the testing matrix --- .github/workflows/main.yml | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e4f81df..8914544 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,33 +15,18 @@ jobs: fail-fast: false matrix: ruby: - - '3.0' - - '3.1' - '3.2' - '3.3' + - '3.4' + - '4.0' rails: - - '6.1' - - '7.0' - - '7.1' - '7.2' - '8.0' + - '8.1' db: - mysql - postgresql - sqlite - exclude: - - rails: '7.0' - ruby: '3.1' - - rails: '7.0' - ruby: '3.2' - - rails: '7.0' - ruby: '3.3' - - rails: '7.2' - ruby: '3.0' - - rails: '8.0' - ruby: '3.0' - - rails: '8.0' - ruby: '3.1' env: DB: ${{ matrix.db }} RAILS_VERSION: ${{ matrix.rails }} From 94c6e9d2ce8c1c46d2920cfa807d60ed5506e569 Mon Sep 17 00:00:00 2001 From: Brendon Muir Date: Mon, 5 Jan 2026 15:15:49 +1300 Subject: [PATCH 7/7] Exclude the Rails 7.2 and Ruby 4.0 combo --- .github/workflows/main.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8914544..d8472b7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -27,6 +27,9 @@ jobs: - mysql - postgresql - sqlite + exclude: + - rails: '7.2' + ruby: '4.0' env: DB: ${{ matrix.db }} RAILS_VERSION: ${{ matrix.rails }}