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
.
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👏 .