Put pairs with Enumerable#sort_by to provide a more expressive, fault-tolerant, and configurable approach to sorting Ruby objects with multiple criteria. Here's a screencast & short blog post that we put out, in case you're interested.
You've probably already put a few gems in there, so why not put Put, too:
gem "put"
Of course after you push Put, your colleagues will wonder why you put Put there.
A neat trick when applying complex sorting rules to a collection is to map them to an array of arrays of comparable values in priority order. It's a common approach (and a special subtype of what's called a Schwartzian transform), but the pattern doesn't have a widely-accepted name yet, so let's use code to explain.
Suppose you have some people:
Person = Struct.new(:name, :age, :rubyist?, keyword_init: true)
people = [
Person.new(name: "Tam", age: 22),
Person.new(name: "Zak", age: 33),
Person.new(name: "Axe", age: 33),
Person.new(name: "Qin", age: 18, rubyist?: true),
Person.new(name: "Zoe", age: 28, rubyist?: true)
]
And you want to sort these people in the following priority order:
- Put any Rubyists at the top of the list, as is right and good
- If both are (or are not) Rubyists, break the tie by sorting by age descending
- Finally, break any remaining ties by sorting by name ascending
Here's what the aforementioned pattern to accomplish this usually looks like using Enumerable#sort_by:
people.sort_by { |person|
[
person.rubyist? ? 0 : 1,
person.age * -1,
person.name
]
} # => Zoe, Qin, Axe, Zak, Tam
The above will return everyone in the right order. This has a few drawbacks, though:
- Unless you're already familiar with this pattern that nobody's bothered to give a name before, this code isn't very expressive. As a result, each line is almost begging for a code comment above it to explain its intent
- Ternary operators are confusing, especially with predicate methods like
rubyist?
and especially when returning magic number's like1
and0
. - Any
nil
values will result in a bad time. If a person'sage
is nil, you'll get "undefined method '*' for nil:NilClass"NoMethodError
- Relatedly, if any two items aren't comparable (i.e.
<=>
returns nil), you'll be greeted with an inscrutableArgumentError
that just says "comparison of Array with Array failed"
Here's the same code example if you put Put in there:
people.sort_by { |person|
[
(Put.first if person.rubyist?),
Put.desc(person.age),
Put.asc(person.name)
]
} # => Zoe, Qin, Axe, Zak, Tam
The Put gem solves every one of the above issues:
- Put's methods have actual names. In fact, let's just call this the "Put pattern" while we're at it
- No ternaries necessary
- It's quite
nil
friendly - It ships with a
Put.debug
method that helps you introspect those impenetrableArgumentError
messages whenever any two values turn out not to be comparable
After reading this, your teammates will be glad they put you in charge of putting gems like Put in the Gemfile.
Put's API is short and sweet. In fact, you've already put up with most of it.
When a particular condition indicates an item should go to the top of a list,
you'll want to designate a position in your mapped sort_by
arrays to return
either Put.first
or nil
, like this:
[42, 12, 65, 99, 49].sort_by { |n|
[(Put.first if n.odd?)]
} # => 65, 99, 49, 42, 12
When a sort criteria should go to the bottom of the list, you can do the same
sort of conditional expression with Put.last
:
%w[Jin drinks Gin on Gym day].sort_by { |s|
[(Put.last unless s.match?(/[A-Z]/))]
} # => ["Jin", "Gin", "Gym", "drinks", "on", "day"]
The Put.asc
method provides a nil-safe way to sort a value in ascending order:
%w[The quick brown fox].sort_by { |s|
[Put.asc(s)]
} # => ["The", "brown", "fox", "quick"]
It also supports an optional nils_first
keyword argument that defaults to
false (translation: nils are sorted last by default), which looks like this:
[3, nil, 1, 5].sort_by { |n|
[Put.asc(n, nils_first: true)]
} # => [nil, 1, 3, 5]
The opposite of Put.asc
is Put.desc
, and it works as you might suspect:
%w[Aardvark Zebra].sort_by { |s|
[Put.desc(s)]
} # => ["Zebra", "Aardvark"]
And also like Put.asc
, Put.desc
has an optional nils_first
keyword
argument when you want nils on top:
[1, nil, 2, 3].sort_by { |n|
[Put.desc(n, nils_first: true)]
} # => [nil, 3, 2, 1]
You're sorting stuff, so naturally order matters. But when building a compound
sort_by
expression, order matters less as you add more and more tiebreaking
criteria. In fact, sometimes shuffling items is the more appropriate than
leaving things in their original order. Enter Put.anywhere
, which can be
called without any argument at any index in the mapped sorting array:
[1, 3, 4, 7, 8, 9].sort_by { |n|
[
(Put.first if n.even?),
Put.anywhere
]
} # => [8, 4, 1, 7, 9, 3]
If you're sorting items and you know some not-comparable nil
values are going
to appear, you can put all the nils on top with Put.nil_first(value)
. Note
that unlike Put.asc
and Put.desc
, it won't actually sort the valuesโit'll
just pull all the nils up!
[:fun, :stuff, nil, :here].sort_by { |val|
[Put.nils_first(val)]
} # => [nil, :fun, :stuff, :here]
As you might be able to guess, Put.nils_last
puts the nils last:
[:every, nil, :counts].sort_by { |val|
[Put.nils_last(val)]
} # => [:every, :counts, nil]
If you see "comparison of Array with Array failed" and you don't have any idea
what is going on, try debugging by changing sort_by
to map
and passing it
to Put.debug
.
For an interactive example of how to debug this issue with Put.debug
, take a
look at this test case.
Many thanks to Matt Jones and Matthew Draper for answering a bunch of obscure questions about comparisons in Ruby and implementing the initial prototype, respectively. ๐๐๐
This project follows Test Double's code of conduct for all community interactions, including (but not limited to) one-on-one communications, public posts/comments, code reviews, pull requests, and GitHub issues. If violations occur, Test Double will take any action they deem appropriate for the infraction, up to and including blocking a user from the organization's repositories.