Skip to content
Open
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
20 changes: 4 additions & 16 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
18 changes: 9 additions & 9 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion lib/positioning.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 57 additions & 21 deletions lib/positioning/healer.rb
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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
127 changes: 127 additions & 0 deletions test/test_repositioning.rb
Original file line number Diff line number Diff line change
@@ -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