brendon / acts_as_list

An ActiveRecord plugin for managing lists.
http://brendon.github.io/acts_as_list/
MIT License
2.05k stars 356 forks source link

Acts As List

Build Status

Continuous Integration Gem Version

ANNOUNCING: Positioning, the gem

As maintainer of both Acts As List and the Ranked Model gems, I've become intimately acquainted with the strengths and weaknesses of each. I ended up writing a small scale Rails Concern for positioning database rows for a recent project and it worked really well so I've decided to release it as a gem: Positioning

Positioning works similarly to Acts As List in that it maintains a sequential list of integer values as positions. It differs in that it encourages a unique constraints on the position column and supports multiple lists per database table. It borrows Ranked Model's concept of relative positioning. I encourage you to check it out and give it a whirl on your project!

Description

This acts_as extension provides the capabilities for sorting and reordering a number of objects in a list. The class that has this specified needs to have a position column defined as an integer on the mapped database table.

0.8.0 Upgrade Notes

There are a couple of changes of behaviour from 0.8.0 onwards:

Installation

In your Gemfile:

gem 'acts_as_list'

Or, from the command line:

gem install acts_as_list

Example

At first, you need to add a position column to desired table:

rails g migration AddPositionToTodoItem position:integer
rake db:migrate

After that you can use acts_as_list method in the model:

class TodoList < ActiveRecord::Base
  has_many :todo_items, -> { order(position: :asc) }
end

class TodoItem < ActiveRecord::Base
  belongs_to :todo_list
  acts_as_list scope: :todo_list
end

todo_list = TodoList.find(...)
todo_list.todo_items.first.move_to_bottom
todo_list.todo_items.last.move_higher

Instance Methods Added To ActiveRecord Models

You'll have a number of methods added to each instance of the ActiveRecord model that to which acts_as_list is added.

In acts_as_list, "higher" means further up the list (a lower position), and "lower" means further down the list (a higher position). That can be confusing, so it might make sense to add tests that validate that you're using the right method given your context.

Methods That Change Position and Reorder List

Methods That Change Position Without Reordering List

Methods That Return Attributes of the Item's List Position

Adding acts_as_list To An Existing Model

As it stands acts_as_list requires position values to be set on the model before the instance methods above will work. Adding something like the below to your migration will set the default position. Change the parameters to order if you want a different initial ordering.

class AddPositionToTodoItem < ActiveRecord::Migration
  def change
    add_column :todo_items, :position, :integer
    TodoItem.order(:updated_at).each.with_index(1) do |todo_item, index|
      todo_item.update_column :position, index
    end
  end
end

If you are using the scope option things can get a bit more complicated. Let's say you have acts_as_list scope: :todo_list, you might instead need something like this:

TodoList.all.each do |todo_list|
  todo_list.todo_items.order(:updated_at).each.with_index(1) do |todo_item, index|
    todo_item.update_column :position, index
  end
end

When using PostgreSQL, it is also possible to leave this migration up to the database layer. Inside of the change block you could write:

 execute <<~SQL.squish
   UPDATE todo_items
   SET position = mapping.new_position
   FROM (
     SELECT
       id,
       ROW_NUMBER() OVER (
         PARTITION BY todo_list_id
         ORDER BY updated_at
       ) AS new_position
     FROM todo_items
   ) AS mapping
   WHERE todo_items.id = mapping.id;
 SQL

Notes

All position queries (select, update, etc.) inside gem methods are executed without the default scope (i.e. Model.unscoped), this will prevent nasty issues when the default scope is different from acts_as_list scope.

The position column is set after validations are called, so you should not put a presence validation on the position column.

If you need a scope by a non-association field you should pass an array, containing field name, to a scope:

class TodoItem < ActiveRecord::Base
  # `task_category` is a plain text field (e.g. 'work', 'shopping', 'meeting'), not an association
  acts_as_list scope: [:task_category]
end

You can also add multiple scopes in this fashion:

class TodoItem < ActiveRecord::Base
  belongs_to :todo_list
  acts_as_list scope: [:task_category, :todo_list_id]
end

Furthermore, you can optionally include a hash of fixed parameters that will be included in all queries:

class TodoItem < ActiveRecord::Base
  belongs_to :todo_list
  # or `discarded_at` if using discard
  acts_as_list scope: [:task_category, :todo_list_id, deleted_at: nil]
end

This is useful when using this gem in conjunction with the popular acts_as_paranoid or discard gems.

More Options

Disabling temporarily

If you need to temporarily disable acts_as_list during specific operations such as mass-update or imports:

TodoItem.acts_as_list_no_update do
  perform_mass_update
end

In an acts_as_list_no_update block, all callbacks are disabled, and positions are not updated. New records will be created with the default value from the database. It is your responsibility to correctly manage positions values.

You can also pass an array of classes as an argument to disable database updates on just those classes. It can be any ActiveRecord class that has acts_as_list enabled.

class TodoList < ActiveRecord::Base
  has_many :todo_items, -> { order(position: :asc) }
  acts_as_list
end

class TodoItem < ActiveRecord::Base
  belongs_to :todo_list
  has_many :todo_attachments, -> { order(position: :asc) }

  acts_as_list scope: :todo_list
end

class TodoAttachment < ActiveRecord::Base
  belongs_to :todo_item
  acts_as_list scope: :todo_item
end

TodoItem.acts_as_list_no_update([TodoAttachment]) do
  TodoItem.find(10).update(position: 2)
  TodoAttachment.find(10).update(position: 1)
  TodoAttachment.find(11).update(position: 2)
  TodoList.find(2).update(position: 3) # For this instance the callbacks will be called because we haven't passed the class as an argument
end

Troubleshooting Database Deadlock Errors

When using this gem in an app with a high amount of concurrency, you may see "deadlock" errors raised by your database server. It's difficult for the gem to provide a solution that fits every app. Here are some steps you can take to mitigate and handle these kinds of errors.

1) Use the Most Concise API

One easy way to reduce deadlocks is to use the most concise gem API available for what you want to accomplish. In this specific example, the more concise API for creating a list item at a position results in one transaction instead of two, and it issues fewer SQL statements. Issuing fewer statements tends to lead to faster transactions. Faster transactions are less likely to deadlock.

Example:

# Good
TodoItem.create(todo_list: todo_list, position: 1)

# Bad
item = TodoItem.create(todo_list: todo_list)
item.insert_at(1)

2) Rescue then Retry

Deadlocks are always a possibility when updating tables rows concurrently. The general advice from MySQL documentation is to catch these errors and simply retry the transaction; it will probably succeed on another attempt. (see How to Minimize and Handle Deadlocks) Retrying transactions sounds simple, but there are many details that need to be chosen on a per-app basis: How many retry attempts should be made? Should there be a wait time between attempts? What other statements were in the transaction that got rolled back?

Here a simple example of rescuing from deadlock and retrying the operation:

You can also use the approach suggested in this StackOverflow post: https://stackoverflow.com/questions/4027659/activerecord3-deadlock-retry

3) Lock Parent Record

In addition to reacting to deadlocks, it is possible to reduce their frequency with more pessimistic locking. This approach uses the parent record as a mutex for the entire list. This kind of locking is very effective at reducing the frequency of deadlocks while updating list items. However, there are some things to keep in mind:

Example:

todo_list = TodoList.create(name: "The List")
todo_list.with_lock do
  item = TodoItem.create(description: "Buy Groceries", todo_list: todo_list, position: 1)
end

Versions

Version 0.9.0 adds acts_as_list_no_update (https://github.com/brendon/acts_as_list/pull/244) and compatibility with not-null and uniqueness constraints on the database (https://github.com/brendon/acts_as_list/pull/246). These additions shouldn't break compatibility with existing implementations.

As of version 0.7.5 Rails 5 is supported.

All versions 0.1.5 onwards require Rails 3.0.x and higher.

A note about data integrity

We often hear complaints that position values are repeated, incorrect etc. For example, #254. To ensure data integrity, you should rely on your database. There are two things you can do:

  1. Use constraints. If you model Item that belongs_to an Order, and it has a position column, then add a unique constraint on items with [:order_id, :position]. Think of it as a list invariant. What are the properties of your list that don't change no matter how many items you have in it? One such property is that each item has a distinct position. Another could be that position is always greater than 0. It is strongly recommended that you rely on your database to enforce these invariants or constraints. Here are the docs for PostgreSQL and MySQL.
  2. Use mutexes or row level locks. At its heart the duplicate problem is that of handling concurrency. Adding a contention resolution mechanism like locks will solve it to some extent. But it is not a solution or replacement for constraints. Locks are also prone to deadlocks.

As a library, acts_as_list may not always have all the context needed to apply these tools. They are much better suited at the application level.

Roadmap

  1. Sort based feature

Contributing to acts_as_list

Copyright

Copyright (c) 2007 David Heinemeier Hansson, released under the MIT license