ranked-model is a modern row sorting library built for Rails 4.2+. It uses ARel aggressively and is better optimized than most other libraries.
As maintainer of both Acts As List and the Ranked Model gems, I've become intimately aquainted 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!
ranked-model passes specs with Rails 4.2, 5.0, 5.1, 5.2, 6.0 and 6.1 for MySQL, Postgres, and SQLite on Ruby 2.4 through 3.0 (with exceptions, please check the CI setup for supported combinations), and jruby-9.1.17.0 where Rails supports the platform.
To install ranked-model, just add it to your Gemfile
:
gem 'ranked-model'
# Or pin ranked-model to git
# gem 'ranked-model',
# git: 'git@github.com:mixonic/ranked-model.git'
Then use bundle install
to update your Gemfile.lock
.
Use of ranked-model is straight ahead. Get some ducks:
class Duck < ActiveRecord::Base
end
Give them an order (integer column):
rails g migration AddRowOrderToDucks row_order:integer
rails db:migrate
IMPORTANT: The _order
table column MUST allow null values. For the reason behind this requirement see issue#167
Put your ducks in a row:
class Duck < ActiveRecord::Base
include RankedModel
ranks :row_order
end
Order the Ducks by this order:
Duck.rank(:row_order).all
The ranking integers stored in the row_order
column will be big and spaced apart. When you
implement a sorting UI, just update the resource by appending the column name with _position
and indicating the desired position:
@duck.update row_order_position: 0 # or 1, 2, 37. :first, :last, :up and :down are also valid
IMPORTANT: Note that you MUST append _position to the column name when setting a new position on an instance. This is a fake column that can take relative as well as absolute index-based values for position.
Position numbers begin at zero. A position number greater than the number of records acts the same as :last. :up and :down move the record up/down the ladder by one step.
So using a normal json controller where @duck.attributes = params[:duck]; @duck.save
, JS can
look pretty elegant:
$.ajax({
type: 'PUT',
url: '/ducks',
dataType: 'json',
data: { duck: { row_order_position: 0 } }, // or whatever your new position is
});
If you need to find the rank of an item with respect to other ranked items, you can use the {column_name}_rank
method on the model instance. {column_name}
is your resource ranking column.
Following on from our examples above, the row_order_rank
method will return the position of the duck object in the list with respect to the order defined by the row_order column.
Duck.rank(:row_order).first.row_order_rank # => 0
Duck.rank(:row_order).third.row_order_rank # => 2
The ranks
method takes several arguments:
class Duck < ActiveRecord::Base
include RankedModel
ranks :row_order, # Name this ranker, used with rank()
column: :sort_order # Override the default column, which defaults to the name
belongs_to :pond
ranks :swimming_order,
with_same: :pond_id # Ducks belong_to Ponds, make the ranker scoped to one pond
ranks :row_order,
with_same: [:pond_id, :breed] # Lets rank them by breed
scope :walking, where(walking: true )
ranks :walking_order,
scope: :walking # Narrow this ranker to a scope
belongs_to :parent, class_name: 'Duck', optional: true
ranks :child_order,
unless: :has_no_parent?, # Rank only ducks that have a parent. Alternatively a Proc or lambda can be passed, e.g. proc { parent.nil? }
with_same: :parent_id
def has_no_parent?
parent.nil?
end
end
When you make a query, add the rank:
Duck.rank(:row_order)
Pond.first.ducks.rank(:swimming_order)
Duck.walking.rank(:walking)
While ranked-model is performant when storing data, it might cause N+1s depending on how you write your code. Consider this snippet:
ducks = Duck.all
ducks.map do |duck|
{
id: duck.id,
position: duck.row_order_rank # This causes N+1!
}
end
Every call to duck.row_order_rank
will make a call to the DB to check the rank of that
particular element. If you have a long list of elements this might cause issues to your DB.
In order to avoid that, you can use the rank(:your_rank)
scope and some Ruby code to get
the element's position:
ducks = Duck.rank(:row_order).all
ducks.map.with_index do |duck, index|
{
id: duck.id,
position: index
}
end
ranked-model scopes your records' positions based on the class name of the object. If you have
a STI type
column set in your model, ranked-model will reference that class for positioning.
Consider this example:
class Vehicle < ActiveRecord::Base
ranks :row_order
end
class Car < Vehicle
end
class Truck < Vehicle
end
car = Car.create!
truck = Truck.create!
car.row_order
=> 0
truck.row_order
=> 0
In this example, the row_order
for both car
and truck
will be set to 0
because they have
different class names (Car
and Truck
, respectively).
If you would like for both car
and truck
to be ranked together based on the base Vehicle
class instead, use the class_name
option:
class Vehicle < ActiveRecord::Base
ranks :row_order, class_name: 'Vehicle'
end
class Car < Vehicle
end
class Truck < Vehicle
end
car = Car.create!
truck = Truck.create!
car.row_order
=> 0
truck.row_order
=> 4194304
If you use ranked_model
with existing data, the following migration (for Rails
6) can be a starting point. Make sure to declare include RankedModel
and
ranks :row_order
in your Duck
before running the migration.
rails g migration AddRowOrderToDucks row_order:integer
Then, adjust the migration:
# e.g. file db/migrate/20200325095038_add_row_order_to_ducks.rb
class AddRowOrderToDucks < ActiveRecord::Migration[6.0]
def change
add_column :ducks, :row_order, :integer
# Newest Duck shall rank "highest"" (be last).
Duck.update_all('row_order = EXTRACT(EPOCH FROM created_at)')
# Alternatively, implement any other sorting default
# Duck.order(created_at: :desc).each do |duck|
# duck.update!(row_order: duck.created_at.to_i + duck.age / 2)
# end
end
end
This library is written using ARel from the ground-up. This leaves the code much cleaner than many implementations. ranked-model is also optimized to write to the database as little as possible: ranks are stored as a number between -2147483648 and 2147483647 (the INT range in MySQL). When an item is given a new position, it assigns itself a rank number between two neighbors. This allows several movements of items before no digits are available between two neighbors. When this occurs, ranked-model will try to shift other records out of the way. If items can't be easily shifted anymore, it will rebalance the distribution of rank numbers across all members of the ranked group.
Record updates to rebalance ranks do not trigger ActiveRecord callbacks. If you need to react to these updates
(to index them in a secondary data store, for example), you can subscribe to the ranked_model.ranks_updated
ActiveSupport notification.
Subscribed consumers receive an event for each rearrangement or rebalancing, the payload of which includes the
triggering instance and the scope
and with_same
options for the ranking, which can be used to retrieve the
affected records.
ActiveSupport::Notifications.subscribe("ranked_model.ranks_updated") do |_name, _start, _finish, _id, payload|
# payload[:instance] - the instance whose update triggered the rebalance
# payload[:scope] - the scope applied to the ranking
# payload[:with_same] - the with_same option applied to the ranking
end
Fork, clone, write a test, write some code, commit, push, send a pull request. Github FTW!
The code is published under the MIT License.
The specs can be run with sqlite, postgres, and mysql:
bundle
appraisal install
DB=postgresql bundle exec appraisal rake
If no DB is specified (sqlite
, mysql
, or postgresql
), the tests run against sqlite.
RankedModel is mostly the handiwork of Matthew Beale:
- madhatted.com is where I blog. Also @mixonic.
A hearty thanks to these contributors:
- Harvest where this Gem started. They are great, great folks.
- yabawock
- AndrewRadev
- adheerajkumar
- mikeycgto
- robotex82
- rociiu
- codepodu
- kakra
- metalon
- jamesalmond
- jguyon
- pehrlich
- petergoldstein
- brendon