diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e4f81df..d8472b7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,33 +15,21 @@ 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' + ruby: '4.0' env: DB: ${{ matrix.db }} RAILS_VERSION: ${{ matrix.rails }} 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 diff --git a/README.md b/README.md index b3ebd27..f76ae73 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,36 @@ 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 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 +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..7cbb695 100644 --- a/lib/positioning/healer.rb +++ b/lib/positioning/healer.rb @@ -1,35 +1,77 @@ module Positioning class Healer - def initialize(model, column, order) + def initialize(model, column) @model = model @column = column.to_sym - @order = order end - def heal + 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) + + scope.order(order).each.with_index(1) do |record, index| + record.update_column @column, index + end + 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 + 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| + 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 + + private + + def each_scope(conditions = {}) if scope_columns.present? - @model.unscope(:order).reselect(*scope_columns).distinct.each do |scope_record| + @model.where(conditions).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 - else - @model.where(scope_record.slice(*scope_columns)).lock! - end - - sequence @model.where(scope_record.slice(*scope_columns)) + lock_scope(scope_record) + yield @model.where(scope_record.slice(*scope_columns)).unscope(:order, :select) end end else @model.transaction do @model.all.lock! - sequence @model + yield @model.unscope(:order, :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] @@ -38,11 +80,5 @@ def scope_columns 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| - record.update_columns @column => index - end - end end end diff --git a/test/test_repositioning.rb b/test/test_repositioning.rb new file mode 100644 index 0000000..2ad1f32 --- /dev/null +++ b/test/test_repositioning.rb @@ -0,0 +1,127 @@ +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_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" + 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