A New Rails 3 Positioning Library: RankedModel

When we started work on the new Harvest Help Center, it was with the fresh perspective of our recent Rails 3 upgrades. We looked for a simple row sorting solution, but the traditional plugin ActsAsList is showing it’s age with old ActiveRecord conventions and naive logic. Rising to the challenge, we rolled up our sleeves and built our own ordering solution, that we have dubbed RankedModel.

ActsAsList

ActsAsList has been around for a long time. I can’t recall what release of Rails it came out with, but it was well before Rails 1.0. The fact that it has remained the default solution for row ordering so long speaks to how simple the problem of row sorting is. There isn’t anything too crazy going on in this code:

  • Keep the order of items in an integer column.
  • When fetching a set of items, default the order statement to sort by that column.
  • When we move an item to a new position, assign all the other items to their new positions.

But there were a few downsides to using ActsAsList:

  • The code does not use ARel. This means we can’t chain sorted item collections into complex ARel queries.
  • If one item is moved, many of the other items are adjusted to compensate for the movement. Many rows in MySQL can be updated by one row’s reassignment.

What’s a modern Rails developer to do?

Enter RankedModel

RankedModel is an ActsAsList replacement developed by the crew here at Harvest, written with ARel from the ground up and using an optimized position storage mechanism. It’s tested with Rspec. Check it out on Github:

https://github.com/harvesthq/ranked-model

RankedModel stores rank as any number in a range of 0 to 65534. When you assign a new position to an item, it is assigned a rank between it’s new neighbors. This means we update one row, not several. Let’s look at an example involving ducks:

  1. Waddly (ranked 13106)
  2. Quacky (ranked 26212)
  3. Webby (ranked 39318)
  4. Beaky (ranked 52424)

If we move Beaky to position #2, we need not adjust the ranks of other items to maintain the new order:

  1. Waddly (ranked 13106)
  2. Beaky (ranked 19659)
  3. Quacky (ranked 26212)
  4. Webby (ranked 39318)

When a newly positioned row is assigned a conflicting rank, the library will redistribute the rank values evenly. The logic is made simpler by a nice abstraction layer that allows developers to focus on the sort logic of a given rank scope and instance.

We think RankedModel is a great, modern solution for a simple problem developers need to deal with often, and we’ve decided to share it with the community-at-large as an open source solution. We sure hope it’s as helpful for you as it has been for us.

Want to work on projects like the Ranked Model Gem? We’re hiring Rails Developers at all skill levels.

9 Comments so far
  • “RankedModel stores rank as any number in a range of 0 to 65534″
    does it mean that you can not manage more than 65535 rows with RankedModel ? that seems pretty low… How can we bypass this limitation ? is there just a configuration setting to tweak that ?

  • Matt Beale February 8, 2011 HARVEST

    @Florent The max-rank value is set as a constant in lib/ranked-model.rb, it should be easy to change.

    The reason 65534 was chosen was to remain below the MEDIUMINT size in MySQL. So out of the box, you can only sort 65534 things at once. Usually you sort a subset of rows with a scope though- even if you have a table of 1,000,000 items, you only want to sort the ones in a given category. The 65534 limit applies to the items in a category, not the total number of items.

  • Got it ! if you scope your sort using another column or a scope method, you have to set the limitation on the maximum number of items within that scope. makes sense.
    Thanks, will definitely give it a try

  • If I have Ducks a, b, c, d, and e and I pond.ducks[2].update_attribute :row_order_position, 4 I have to pond.reload to be able to view the ducks in the order I specified. Is that intended, anticipated and desired behaviour?

  • Matt Beale March 29, 2011 HARVEST

    @Paul that’s a feature of Rails and ARel. The ducks collection has already been loaded from the database and cached, so yes, your code needs to reload it from the database if you want it to be in a new order.

  • Right. Perhaps my question was unclear. I can see *why* it’s happening that way, but I’m not sure that it’s right. Shouldn’t the RankedModel interface actually change the order in the instance without requiring a database reload? This is a philosophical question more than a practical one, as I can appreciate that for all intents and purposes for a web app this is not actually an issue.

    I guess I would expect to say pond.ducks[2].row_order_position=4 and have the structure in memory be correct. But then that would require a separate pond.ducks[2].save, which may not be ideal (even if that’s the way I think of it).

  • I’m pretty concerned about the possibility of race conditions. Say you have two slices each running several mongrels/unicorns/whatever. Say you have two users attempting to update the ranking of the same set of items, at the same time.

    What protection is there against anomalous outcomes, collisions, and the whole thing blowing up in their faces?

  • Barry Hess May 7, 2012 HARVEST

    @Carl Good question. Please write an issue in the GitHub project so someone can take a look down the road. Thanks!

  • @Barry Hess
    Man, it is not the issue, really.
    And question is not that good either ;)

    Data will simply be rewritten – this is a simplest strategy to handle synchronization of data modifications, which is used by default by ActiveRecord, and any other ORM in any other language, and without ORM, when you just use plain old SQL calls you need to explicitly define and implement strategy for synchronization of concurent data modifications, if you need such thing.

Comments have been closed.
The HARVEST Blog News & small business tips from your beloved time tracking & invoicing app.