Skip to content

Farbafe/acts_as_ranked_list

Repository files navigation

ActsAsRankedList

This gem is based off of the ActsAsList gem. It rewrites the gem using floating point position (or rank) for items. The benefit of using floating point ranks is the ability to insert an item inbetween items without updating the other items' positions.

Also supports having a(n) (float/integer) step between ranks that achieves the same thing. Can also use a step <1.0 to rank more items than is allowed by a database's max float/integer column restrictions.

Installation

Install the gem and add to the application's Gemfile by executing:

$ bundle add acts_as_ranked_list

or by adding gem "acts_as_ranked_list" to the Gemfile and running bundle install.

If bundler is not being used to manage dependencies, install the gem by executing:

$ gem install acts_as_ranked_list

Usage

Basic usage

This gem allows you to easily rank ::ActiveRecord items without worrying about the underlying logic. After installing the gem, add the following acts_as_ranked_list to the ::ActiveRecord model, for example:

class MyModelName < ::ActiveRecord::Base
    acts_as_ranked_list
end

When you create a new MyModelName item, it will be ranked among the list of existing MyModelName items. You can increase/decrease the item's rank by the following methods:

item_a = MyModelName.create!
item_a.increase_rank
item_a.decrease_rank

You can get the current rank of the item by the following:

item_a = MyModelName.create! # is at the bottom of the list, highest rank item
item_b = MyModelName.create! # is at the bottom of the list, lowest rank item
item_a.current_rank # 1
item_b.current_rank # 2

Note that the list is viewed as ascending order of rank, last updated at, id. So item_b with rank 2 is lower in the list than item_a with rank 1.

You may get the highest items in the list sorted by rank by the following methods:

my_existing_item = MyModelName.create! # placed top in the list
my_new_item = MyModelName.create! # placed bottom in the list
MyModelName.get_highest_items # ActiveRecord::Relation with results [my_existing_item, my_new_item]

You may get the highest/lowest item by specifying a number as the first argument to the get_highest_items/get_lowest_items methods, such as:

my_existing_item = MyModelName.create! # placed top in the list
my_new_item = MyModelName.create! # placed bottom in the list
MyModelName.get_highest_items(1) # ActiveRecord::Relation with results [my_existing_item] # Note the result is an array
MyModelName.get_lowest_items(1) # ActiveRecord::Relation with results [my_new_item]
MyModelName.get_lowest_items(2) # ActiveRecord::Relation with results [my_new_item, my_existing_item] # Note the order of the returned results
MyModelName.get_highest_items(50000) # ActiveRecord::Relation with results [my_existing_item, my_new_item] # Note the number of requested results

Advanced Usage

For the next examples, each will be initialized to the following:

class TodoItem < ::ApplicationRecord
    # the rank column is named "priority" (without quotation marks) for this table
    # new items are added as highest priority
    acts_as_ranked_list column: "priority", adds_new_at: :highest, step_increment: 1.0
end

design_reusable_plastic_bag_graphic = TodoItem.create!(title: "Design the front and back graphic on the reusable plastic bag")
exercise = TodoItem.create!(title: "Run for 8 miles")
health_check = TodoItem.create!(title: "Drink lemon water")
print_on_shirt = TodoItem.create!(title: "Print a prototype design on the shrit to check quality")
# items and their priorities in ascending order: (the actual result is an array of the items, but the variable name and rank are shown here for simplicity)
#   [["print_on_shirt" ... , 0.125], ["health_check" ... , 0.25], ["exercise" ... , 0.5], ["design_reusable_plastic_bag_graphic" ... , 1.0]]
# the highest prioritised item is "print_on_shirt"
# the lowest prioritised item is "design_reusable_plastic_bag_graphic"

Query position of current item

You can check if an item is the highest item or the lowest item in the list by using the highest_item? or lowest_item? instance methods.

design_reusable_plastic_bag_graphic.lowest_item? # true
design_reusable_plastic_bag_graphic.highest_item? # false
print_on_shirt.highest_item? # true

Get higher or lower items

You can get the higher/lower items by using the instance methods get_higher_items or get_lower_items:

exercise.get_higher_items # items and their priorities (note the order): [["health_check", 0.25], ["print_on_shirt", 0.125]]

You may pass in optional arguments to control how the results are returned. If the first argument is 0, it will return all higher/lower items.

design_reusable_plastic_bag_graphic.get_higher_items(2, "ASC") # [["health_check", 0.25], ["exercise", 0.5]]

Scoping items

You may use scopes to constrain your items to a condition. This means you can group items together, and keep them separate from irrelevant items. For example, you may have 2 todo lists, one each for personal or work related tasks. You may fetch each list of todo items separately, and interact with them separetely using scopes.

We'll add a new table, and ::ActiveRecord model to help demonstrate this feature. For possible options on scope, refer to the docs on service.rb.

class TodoList < ::ActiveRecord::Base
    has_many :todo_items
end

class ScopedTodoItem < ::ActiveRecord::Base
    belongs_to :todo_list # add to table an integer foreign key column `todo_list_id`

    acts_as_ranked_list scopes: { todo_list: nil }
end

work_todo_list = ::TodoList.create!(title: "Chores at work :eye_roll:")
personal_todo_list = ::TodoList.create!(title: "Hobbies to enjoy!!! :partying_face:")
scoped_work_todo_item = ::ScopedTodoItem.create!(title: "Create a new table to store temporary data", list: work_todo_list, rank: 1)
scoped_personal_todo_item = ::ScopedTodoItem.create!(title: "Assemble the new couch to enjoy sitting on", list: personal_todo_list, rank: 1)

In the above example, scoped_personal_todo_item and scoped_work_todo_item have the same rank but are in different scopes. So they do not have colliding ranks, and can be fetched/mutated independently of each other.

You may also use a string, boolean, integer, symbol, named scopes, custom scopes (via a ::Proc) or all of them together on the same model.

class WorkTodoItem < ScopedTodoItem # Note the parent class
    scope :work, -> { where(todo_list: ::TodoList.find_by(...)) }
    default_scope { :work }
    acts_as_ranked_list scopes: { todo_list: :work } # or
    acts_as_ranked_list scopes: { todo_list: :work, week_day: ::Proc.new { "day_of_week = 'tuesday'" } } # or
    acts_as_ranked_list scopes: { todo_list: :work, manager: "Mr. Disney", priority_bracket: :top, related_to_a_paying_customer: true, feature_ticket_number: 42 }
    # these scopes will require the relevant column names on the table
    # such as `manager (string), priority_bracket (string), related_to_a_paying_customer (boolean), feature_ticket_number (integer), week_day (preferred enum on integer, or string)
end

Check if the current item is ranked

If the value of the rank column for the instance is nil then the item is not ranked. This item will still interact with the list when running queries such as highest_item? and so on. This item will be given a rank when spreading ranks.

design_reusable_plastic_bag_graphic.is_ranked? # true

You may create a new item with nil rank as follows:

## this persists the record, but skips callbacks
::TodoItem.with_skip_persistence { ::TodoItem.create!(rank: nil) }

Move rank relative to another item

Instead of updating rank one or down one position at a time, you can move above/below another item, using set_rank_above or set_rank_below instance methods:

design_reusable_plastic_bag_graphic.set_rank_above(health_check)

This can be used together with the get_highest_items(1) class method to move item to the top of the list.

design_reusable_plastic_bag_graphic.set_rank_above(TodoItem.get_highest_items(1).first)

Persistence and persistence callbacks

Each model with the acts_as_ranked_list has class methods to skip persistence to the database, and persistence callbacks.

Skipping persistence is useful if you want to mass update items, and persist once at the end. Persistence callbacks is useful for hooking into the life cycle of the updated item with regards to its rank, for example to send a webhook to all subscribers notifying them of an updated rank.

You can use the class method with_skip_persistence as follows:

TodoItem.with_skip_persistence do
    design_reusable_plastic_bag_graphic.update(rank: 20.3)
    exercise.update(rank: 5.2)
    health_check.update(rank: 7.1)
    print_on_shirt.update(rank: 92.1)
end
::TodoItem.bulk_import!(
    [design_reusable_plastic_bag_graphic, exercise, health_check, print_on_shirt],
    on_duplicate_key_update: {
        conflict_target: [:id],
        columns: [:priority, :updated_at]
    }
) # uses the `activerecord_import` gem, or any other bulk update method to mass save changes to the database in 1 query

You may also pass in an array of classes to the with_skip_persistence method to skip persistence for these ::ActiveRecord models which use the acts_as_ranked_list concern.

TodoItem.with_skip_persistence([FootballTeam]) do # the calling class is added by default, in this case: `TodoItem`
    design_reusable_plastic_bag_graphic.increase_rank
    exercise.increase_rank
    health_check.set_rank_below(print_on_shirt)
    print_on_shirt.set_rank_below(design_reusable_plastic_bag_graphic)

    instance_of_other_model = FootballTeam.create(name: "MineerPul")
    instance_of_other_model.decrease_rank
end
::TodoItem.bulk_import!(
    [design_reusable_plastic_bag_graphic, exercise, health_check, print_on_shirt],
    on_duplicate_key_update: {
        conflict_target: [:id],
        columns: [:priority, :updated_at]
    }
) # uses the `activerecord_import` gem, or any other bulk update method to mass save changes to the database in 1 query

::FootballTeam.bulk_import!(
    [instance_of_other_model],
    on_duplicate_key_update: {
        conflict_target: [:id],
        columns: [:rank, :updated_at]
    }
)

Avoiding collisions

You can control whether to spread ranks or not on collisions by using the option avoid_collisions: true (by default) on using the concern in your ::ActiveRecord model. You can change this setting on a per-block per-class basis by using the following class method:

# disallows collisions
TodoItem.with_avoid_collisions(true) do # do spread ranks on collisions
    TodoItem.find(1).update(rank: 1)
    ... # you may update more than one record too
end # items with their new ranks [["health_check", 1.0], ["exercise", 2.0], ["print_on_shirt", 3.0], ["design_reusable_plastic_bag_graphic", 4.0]]
# allows collisions
TodoItem.with_avoid_collisions(false) do # do not spread ranks on collisions
    TodoItem.find(1).update(rank: 1)
end # items with their new ranks [["health_check", 0.25], ["exercise", 0.5], ["print_on_shirt", 1.0], ["design_reusable_plastic_bag_graphic", 1.0]]

Spread ranks

You can spread ranks so that the difference between each rank and the next is set to the step_increment. This is useful for:

  • Being able to rerank items again without overflowing column's max precision.
  • Human-readable viewing purposes. This is not recommended. The rank should be human-readable (or not viewable) at the view (presentation) layer.

If avoid_collisions = true (by default). Then you do not have to use spread ranks manually. If the database raises an overflow error when mutating a rank, then it could be time to recalibrate the ranks in the table. You should handle this case in your code, and spread ranks. This is very infrequent. For example, in postgres this error will be raised when using the default precision of 16_383 digits after the decimal of a decimal column type in postgres versions 9.1+:

ActiveRecord::RangeError: PG::NumericValueOutOfRange: ERROR:  value overflows numeric format

Items are spread in, ascending order of each, by:

  1. scopes
  2. rank
  3. time updated columns (updated_at for example)
  4. primary key (id for example)

To spread ranks:

TodoItem.spread_ranks
TodoItem.get_highest_items # items with their new ranks [["print_on_shirt", 1.0], ["health_check", 2.0], ["exercise", 3.0], ["design_reusable_plastic_bag_graphic", 4.0]]

Things to beware of

Ranking more items in a databse than the max integer column

Imagine the scenario where you have 199 records to rank, and you must rank them in a decision/numeric column that allows 2 digits before the decimal point, and 2 digits after the decimal point. I.e the max number that can be stored is 99.99.

If you use a step_increment of 1 you will be faced with a max float/integer overflow error by the database, as some of the ranks are more than 2 digits before the decimal point. You may use a decimal step_increment that is less than 1, and suitable for this scenario, and ranking will work as expected, without errors.

class CrammedTodoItem < ::ActiveRecord::Base
    acts_as_ranked_list step_increment: 0.5
end

crammed_todo_items = []
CrammedTodoItem.with_skip_persistence do
    199.times { crammed_todo_items << CrammedTodoItem.create }
end

CrammedTodoItem.bulk_import!(crammed_todo_items) # persist 199 records in a mass insert method using gem `activerecord-import` or other method

CrammedTodoItem.spread_ranks # ensures every step is 0.5

CrammedTodoItem.get_highest_items.pluck(:rank) # [0.50 .. 99.50]

Floating point precision and rounding, and max float/integer column overflow

Not all software can handle the precision handled by the database. The weakest link will cause issues to the entire flow of ranking items using floating-point precision. You may wish to convert to string and back to float if you need to. This must be done by the database layer as you'd have already lost precision if you let any other layer cast the values.

An alternative solution is to use a high step_increment to not get into rounding errors. This also brings its own problems of max integer overflow, but that is usually easier to plan in advance for, and does not fail silently. If you do this, it is best to use a large number that is a power of 2. This helps reduce the number of collisions as much as possible.

Some database services do not fail loudly and will persist a rounded or incorrect value altogether. You may set up an ::ActiveRecord validation on the model to check for the precision before saving, and raise your own validation error, and handle recalibrating ranks.

Performance

For best results, it is recommended to benchmark and compare with real life scenarios. Depending on your use case, different strategies may be faster. Using a large step_increment that's a power of 2 in an integer-based column (i.e no decimals) may be faster. The gem will continue to work as expected, but there may be more frequent collisions, which would be handled automatically but may slow down performance. So experiment and kindly share your learnings 🙏. You should also check other ruby gems that rank and sort ::ActiveRecord models.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run bundle exec rspec ./spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

You may also run yardoc to build the docs locally. And yard server to serve the built docs.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/Farbafe/acts_as_ranked_list.

License

The gem is available as open source under the terms of the MIT License.

About

Ranks ActiveRecord models by floating-point columns to avoid collisions.

Resources

License

Stars

Watchers

Forks