Introducing Priora: An Object Prioritization Utility Gem for Ruby

TL;DR: I’ve released Priora, a new gem that helps in prioritizing collections of objects in Ruby in a friendly way! Check it out (GitHub / RubyGems).
In this post, I will discuss the motivation, design and agenda I have picked in creating this utility. Parts of this story have been extracted into the library’s README file on GitHub.

The other day, a fellow developer at work asked for my opinion regarding some pull request. She was working on a task that required prioritizing an array of objects according to some specified business logic. I’ll refer to the class of these objects as Post in this story. It is a data class whose instances each know about author, like_count and is_sponsored:

class Post
attr_reader :author, :like_count, :is_sponsored

def initialize(author:, like_count:, is_sponsored:)
@author = author
@like_count = like_count
@is_sponsored = is_sponsored
end
end

My colleague came up with a dedicated service class to handle the prioritization task and asked me what I thought about that approach. She explained that she encountered several peculiarities and various details that needed to be taken care of while implementing the prioritization procedure, and that made her feel as if a dedicated class was the right call. After familiarizing myself with the various business requirements, I asked her whether these objects are actually sortable on their own: perhaps, instead of having an additional class to handle the prioritization, she could implement the spaceship operator (<=>) upon Post and then simply sort and reverse the array.

Alas, the prioritization logic of Post instances as required in this task was not globally valid: it became apparent that other usages of this class have different sorting needs. Hence, it was not possible to define <=> upon Post. Ruby does offer Enumerable#sort_by that accepts a block in order to supply a custom sorting scheme, one that could fit to the case in question specifically, but then another problem came up — some of the attributes to prioritize the array by were booleans, and these are not sortable out-of-the-box. Other languages implicitly convert true to 1 and false to 0 and thus allow their sorting; Ruby, however, does not.

Since monkey-patching TrueClass and FalseClass was out of the question, the other option was converting these values ad-hoc. Intuitively, this felt a bit too much to me: that prioritization class was doing too many things and the actual attributes to prioritize by were overshadowed by remotely related code. I told my colleague I needed some time to think about that.

Comes in Priora

Since this was not the first time I’ve stumbled upon such a problem, I thought it would be nice to have a utility library that helped in getting a collection prioritized. While sorting as a concept is well-known thanks to computer science studies and algorithms courses, prioritization is not a synonym for it, as it lies one abstract level above it; prioritization is the usage of sorting for applicable purposes (“business logic”) in a way that arranges the most important objects in a collection to come up first. I looked for an existing solution over RubyGems and the web but didn’t find what I was looking for, so I decided to write it down myself, and came up with Priora.

I wanted an easy way to sort data objects according to priorities, which are basically data points the objects reveal (a.k.a getters), without the hassle of converting true, false and nil into sortable values. I also wanted the ability to define priorities upon a class in a fixed way, but an API flexible enough to enable ad-hoc calls with priorities list as parameter as well.

Priora in Action

For demonstration purposes, let’s get us three Post instances with distinct attributes:

low_like_count_sponsored = Post.new(author: 'Jay C.',
like_count: 10, is_sponsored: true)
high_like_count_unsponsored = Post.new(author: 'Aaron R.',
like_count: 90, is_sponsored: false)
high_like_count_sponsored = Post.new(author: 'Don Y.',
like_count: 90, is_sponsored: true)

Using Priora, we can easily get the collection prioritized according to our needs:

unprioritized_array = [high_like_count_unsponsored, low_like_count_sponsored, high_like_count_sponsored]
prioritized_array =  [high_like_count_sponsored, high_like_count_unsponsored, low_like_count_sponsored]
Priora.prioritize(unprioritized_array, by: [:like_count, :is_sponsored]) == prioritized_array
=> true

This example demonstrates how Priora.prioritize accepts an unprioritized collection and a list of priorities. The latter is passed in through the by parameter. It expects an array of symbols, getters that represent the desired prioritization logic. In this example, we wish to have the Post objects with most likes first, with sponsored posts being a secondary priority in case two or more Post objects have the same like_count.

https://pixabay.com/en/ropes-ship-barge-sea-nautical-2151683/

Fixed Class Priorities

In case we can commit to the prioritization between Post objects — i.e., we do not need the flexibility of changing the priorities each time (which was not the case for my colleague) — we can include the Priora module in our class and declare the fixed priorities using the prioritize_by class macro and gain shorter invocation later on. Our class would then read like this:

class Post
include Priora
prioritize_by :like_count, :is_sponsored
  attr_reader :author, :like_count, :is_sponsored
  def initialize(author:, like_count:, is_sponsored:)
@author = author
@like_count = like_count
@is_sponsored = is_sponsored
end
end

And getting the prioritized array would read like this:

Priora.prioritize(unprioritized_array) == prioritized_array
=> true

Using the prioritize_by class macro increases the readability of your code for the cost of flexibility. By employing it, priorities are declared
in-class and Priora can fetch them implicitly. For some cases this might be the right choice while for others the explicit style is more suitable.

Although it might seem like this option defines the spaceship operator upon the including class, it does not, actually. The only thing it does is store the declared priorities in a class-instance variable, thus enabling to Priora to consume it later on.

Advantages Over Using Custom Enumerable#sort

One might come up with the following snippet as an equivalent solution:

unprioritized_array.sort { |a, b|
[a.like_count, a.is_sponsored ? 1 : 0] <=>
[b.like_count, b.is_sponsored ? 1 : 0] }.reverse == prioritized_array
=> true

Which is, of course, correct. However, I find several issues with this code:

  • It is more verbose and prone to errors.
  • It declares the prioritization logic twice. We cannot use the lighter sort_by since booleans are not sortable out-of-the-box.
  • It handles the conversion of a boolean value (true / false) into a sortable value (1 / 0) inline, thus mixing levels of abstractions and confusing the potential reader.

Reverse Sorting, Extended: An Agenda

As mentioned before, Priora is based on the presumption that when we talk about a prioritized collection, we often refer to the outcome of sorting it and then reversing the result. That is because we naturally think about sorting in an ascending fashion, from small to large, while when we talk about “priorities”, or “top priorities”, we usually think of the largest items appearing up first. When these items are data objects, it is up to us to define what makes one object larger than another.

Directional Priorities

Obviously, this is not always true and some prioritization processes should give precedence to smaller items first; Priora supports this as well. You may change the prioritization direction for a specific priority:

Priora.prioritize(unprioritized_array, by: [[like_count: :asc], :is_sponsored])
=> [low_like_count_sponsored, high_like_count_sponsored, high_like_count_unsponsored]

We can see that the Post with the low like_count comes up first, however the two high like_count posts are prioritized by is_sponsored, so the sponsored Post comes up first.

Implicit Conversions

As noted, in order to offer a friendly, domain-logic-focused API, Priora takes care of converting non-sortable values, such as true, false or nil, into sortable values. By default, it assumes that true is larger than false and that nil evaluates to 0.

You may override these implicit conversions with your own lambdas, as well as supply your own custom lambdas for other classes (and perhaps override their existing prioritization logic!).

For example, if we wished to prioritize attributes of class String by their length, we could configure Priora accordingly beforehand:

Priora.configuration.add_conversion_lambda(String, lambda { |value| value.length })

Conversion lambdas are also removable, should that need arise:

Priora.configuration.remove_conversion_lambda(String)

I followed the same guideline as before — assuming the reasonable common use: boolean attributes of true value are often of higher priority than those of false values. That false < true is a common idiom in many other programming languages. The fact that one might wish to prioritize objects by boolean attributes implies that a choice must be made, so I adopted the prevalent convention. I also think it makes a lot of sense, in most cases. Whenever it is not, just declare the priority as :asc explicitly.


Thank you for reading all the way through! I encourage you to try and use Priora yourself and see if it helps you with your prioritization needs as well. If you encounter any issues or have some feature request, you are welcome to open an issue on GitHub or contact me directly.

If you like my project, please give it a ⭐star⭐ on GitHub.

If you found this story interesting or useful, please support it by clapping it👏 .