tl;dr
- We have a new gem called Put
- I made a screencast demoing sorting complex objects by multiple conditions, both in pure Ruby and with Put
- Here's the example code
- After seeing Put'sREADME, I was asked to refrain from any "put" wordplay in this post
Not too long; did read
I have a confession to make: I've been programming Ruby since 2004 and I still get tripped up whenever I encounter the <=>
spaceship operator. As recently as this week, I've caught myself slowly and unconfidently working out the rules in my head: "okay, if the receiver is comparably greater than the argument, it should return -1
, right?"
To avoid this confusion, many Rubyists reach for the Enumerable#sort_by method at the first sign of trouble. It lets us pass a block that reduces our complex objects into simpler ones Ruby can sort for us (like String
andNumeric
).
For example, we could sort people by age ascending like this:
people.sort_by { |person| person.age }
Or, if we need to sort by age descending, we could make the ages negative:
people.sort_by { |person| -1 * person.age }
And if we need to add a secondary sorting condition—say, people of the same age should be sorted by name ascending—we can return an array in our block that returns the array in priority order, relying on the fact that Ruby sorts arrays stepwise by element:
people.sort_by { |person|
[
-1 * person.age,
person.name
]
But what if someone's age
or name
is nil? Then you'll need guard clauses to avoid an ArgumentError
:
people.sort_by { |person|
[
person.age.nil? ? 0 : -1 * person.age,
person.name || "zzz"
]
}
Wow! It didn't take long for our simple one-off sort to become a bit of a mess.We'd need code comment for these rules to make sense to others.
To make these multi-criteria sorts more expressive, terser, and nil-safe, I wrote a little gem called Put last week that can clean up sort_by
blocks:
people.sort_by { |person|
[
Put.desc(person.age, nils_first: true),
Put.asc(person.name)
]
}
This is a new pattern to a lot of programmers, so I recorded this screencast building a non-trivial sort_by
block in pure Ruby, then translating the same conditions to the new Put
API. I hope you'll check it out! You can find the video's example code here.
If you enjoy the video, we'd love if you subscribed to our fledgling YouTube channel and our e-mail newsletter to stay in touch with what I and my fellow Double Agents are working on! 🕵️
[00:00] (upbeat electronic chimes)
[00:03] - Hello, I'm here to talk about my new gem, Put.
[00:07] Now Put helps you sort objects in memory using Ruby
[00:10] and it does so following a particular pattern that you may
[00:12] or may not be familiar with
[00:14] using a numerable dot sort by and returning an array.
[00:17] So because that pattern isn't super well known,
[00:20] I figured it would make sense to first show an example
[00:22] in pure Ruby and then show off why the Put gem
[00:25] can help make your code a little bit cleaner
[00:27] a little bit safer, and definitely more expressive
[00:30] in terms of what you're intending when you're sorting
[00:32] by multiple criteria.
[00:34] Now, for want of an example,
[00:36] I was thinking about how a lot of folks are having to return
[00:38] to the office soon,
[00:39] not at Test Double, 'cause we're remote first
[00:41] but I'm kind of ginning up some empathy to imagine
[00:44] that I would not be looking forward
[00:45] to returning to break rooms and the particular
[00:48] smells that happen when people microwave fish.
[00:52] So, as a programmer,
[00:54] I'm like a lot of programmers
[00:55] whenever I've got a social problem
[00:57] and I should have a hard conversation with somebody.
[00:59] I'd much rather try to solve that
[01:00] with software and technology and you know.
[01:03] So instead of actually asking someone
[01:05] not to microwave their fish,
[01:06] maybe I'd write a program that would build a duty roster
[01:09] for everyone taking turns cleaning the break room.
[01:11] So we're gonna implement break room sort today.
[01:15] And what it is,
[01:16] is going to prioritize all of the employees
[01:18] in terms of when they should be next responsible
[01:21] for cleaning up the break room.
[01:22] First of all, you know
[01:24] we're gonna sort all the current employees
[01:25] to the top and then anyone with mobility impairments
[01:29] or an accommodation last.
[01:32] Next, anyone who's cleaned the break room
[01:35] least recently should be their turn next.
[01:38] Or if they've never cleaned it.
[01:39] Whoever's microwave fish most recently
[01:41] should be the tiebreaker after that
[01:44] because they're the problem.
[01:46] And next, if that's still a tie,
[01:49] if all those conditions are met,
[01:51] then we should have the more senior people
[01:53] in the organization be responsible for cleaning.
[01:56] Servant leadership and all that.
[01:57] So CEO cleans the before staff
[02:00] and then the last tiebreaker is whoever's located closest
[02:03] to the break room using latitude and longitude.
[02:06] And so that's our exercise today.
[02:11] So let's go ahead and get started.
[02:13] We're gonna open up vs code.
[02:14] I've already done some of the work here
[02:16] to just sort of like flesh things out.
[02:18] So we've got users, we've got a break room,
[02:21] we've got a even a stub method
[02:22] for sorts break room duty roster
[02:27] and a sort method.
[02:28] You can see that these default (indistinct) here are just
[02:30] gonna generate a break room example and user examples.
[02:33] So break room has a name, latitude, longitude
[02:36] whether it's clean or not.
[02:37] And these are just using the faker gem to make up fake ones.
[02:40] I'll share all this code later.
[02:42] And a user has name, active, the accommodations
[02:44] that they have.
[02:45] Last clean break room, last microwaved fish,
[02:47] level, lat, longitude and so forth.
[02:50] All right, so one way we could do this is,
[02:53] and one of my favorite ways to do it.
[02:55] It's not the fastest way on the planet,
[02:56] but we're already in memory with Ruby.
[02:58] So presumably we couldn't sort this in a database.
[02:59] If you could just do this
[03:00] with an order by of course that would be faster.
[03:02] But in this case,
[03:03] we've got some custom criteria that we wanna search.
[03:05] Maybe we don't have a whole lot of employees
[03:07] so it's safe to just pull all the users in
[03:09] and sort in memory with Ruby.
[03:11] And so we're gonna do that using enumerable dot sort by.
[03:14] Sort by and user.
[03:18] So we take all the users, we get a user
[03:21] and then we can sort by any one condition.
[03:22] So we could just say,
[03:23] user.active and now that would be true and false.
[03:28] So we run this file here
[03:31] which is gonna call sorts break room duty roster.
[03:36] And it calls sort and then describes the first 10 items.
[03:40] However, it didn't do that
[03:41] because it tried to compare true and false.
[03:44] So those are not comparable.
[03:45] That means that like a lot of the work here
[03:47] is gonna gonna be taking non-comparable things
[03:50] like two Boolean values
[03:52] and making them comparable like numbers.
[03:54] So if they're active then we'll say zero.
[03:57] And if they're not active, we'll say one.
[03:59] So that the active stuff is lower value and goes above,
[04:03] goes first before stuff with a higher value of one.
[04:06] So that means the active stuff will be sorted at the top
[04:09] and we should be able to see, okay, cool, active stuff.
[04:11] Now one of the cool facts of how sorting works
[04:13] in Ruby is that arrays are sorted one element at a time.
[04:16] So that means that we can actually have multiple conditions.
[04:20] We could say first show me all the active users on top
[04:24] and then we were talking about mobility accommodations.
[04:27] We could say, user.accommodations
[04:33] I'm not good at spelling this.
[04:35] I wish auto complete saved me.
[04:37] Include mobility.
[04:40] Same sort of trick here we have to use (indistinct)
[04:43] to convert this into something that's comparable.
[04:45] We're gonna do the opposite
[04:46] 'cause we want this to be descending.
[04:47] So if you have such an accommodation
[04:50] we're gonna say one.
[04:53] And if not, then we're gonna say zero.
[04:55] So that means that the folks
[04:58] with the mobility impairment would not be asked to go
[05:00] and clean the break room
[05:01] and folks that do,
[05:02] they'd do not.
[05:06] They'd sort top.
[05:07] So it's basically just like fling all this stuff
[05:09] to the top and all these people to the bottom
[05:10] of this list as as the first couple criteria
[05:14] while we get to our other sorting criteria.
[05:17] All right?
[05:18] So we could run that here
[05:19] and we should be able to scan the list
[05:22] and not see any mobility accommodations they might have
[05:25] like another one there.
[05:30] At any given time we can kind of just look
[05:31] at like which tiebreaker are we looking at?
[05:33] By commenting out the ones above us.
[05:36] And yeah it looks good.
[05:38] We're not sorting the opposite way.
[05:40] All right, the next case
[05:42] that we have now this is time based
[05:44] is when did they clean the break room?
[05:48] Last cleaned break room app.
[05:51] It's hard to type and talk at the same time.
[05:54] Here we can just sort by the date, right?
[05:57] Except we can't do that because we have some nil cases.
[06:02] And again, nil is not comparable
[06:04] with much of anything by default.
[06:05] And so comparison of array with array failed
[06:08] this is all served by gives you if any...
[06:11] There's a thousand arrays of arrays in here.
[06:13] If any single one of them failed
[06:14] all you get is argument error.
[06:15] It doesn't tell you anything.
[06:17] So when you get into the Put gem,
[06:19] there's a Put.description or Put.debug,
[06:23] sorry method.
[06:24] And you can pass it this array of array
[06:26] and it'll try to give you some sense
[06:27] of where our comparison is breaking down.
[06:29] But failing that,
[06:30] all we gotta do is we gotta know nils are not okay.
[06:33] So we can say "Hey, if it's nil, maybe"
[06:38] Oh yeah double.
[06:39] Turners are so tricky when you have a predicate method.
[06:42] We could say time parse
[06:44] give it a long, long ago value like 1999.
[06:48] Okay?
[06:49] Otherwise we will give you
[06:51] the user last cleaned break room at value.
[06:55] So that should give us probably a bunch
[06:57] of people with nil cleaned break room mats.
[07:00] Yeah, at the very top the nil ones.
[07:03] There's only a couple and then,
[07:06] oh yeah, there's like seven or eight.
[07:08] Then yeah, these ones are not very recent.
[07:10] They're 2021.
[07:11] I think we're only generating dates out a year in a arrears.
[07:15] So that's cool.
[07:16] And then the next date was user last microwaved fish at.
[07:24] Now we can't just do this
[07:26] because if it was just the date,
[07:29] it would be ascending.
[07:30] And so if it's ascending.
[07:32] it's gonna actually be the most recent
[07:35] fish microwavings would go to the bottom of the list.
[07:39] Additionally, we're gonna have some nils in there
[07:41] 'cause some people will have never microwaved fish.
[07:43] And so here we could say,
[07:44] "All right, first of all if it's nil,
[07:49] so would we put a time in the future?"
[07:51] No, we wouldn't do that 'cause like we wanna get
[07:53] to something that's like going to ascend in the right order.
[07:56] And one way to do that would be to like
[07:58] think of the duration,
[07:59] like how long has it been since the last microwave fish?
[08:01] So we could do that by...
[08:04] Just to illustrate time.now minus when you did this.
[08:09] Now that would be a duration.
[08:13] If I run this though, I'm still gonna have nil.
[08:14] So it's gonna be, I can't convert that.
[08:16] So instead of a turner,
[08:18] another thing I might do is like a short circuit
[08:20] or a statement.
[08:21] I could just say time.now.
[08:25] Of course if I say zero,
[08:27] that'll make it very, very low
[08:29] which I'll sort it higher.
[08:31] Oh goodness.
[08:32] If I say zero, that'll make it high.
[08:34] Yes.
[08:35] So that's what I want I wanna say so.
[08:40] To isolate to just this condition
[08:41] we can comment on this stuff and then take a look.
[08:44] So we're gonna run again.
[08:47] Array with array failed again.
[08:48] Oh no, what'd I do?
[08:52] What did I do?
[08:54] Oh.
[08:59] We're gonna just parse a very old date again
[09:01] 1900-01-01.
[09:06] Somebody's probably screaming at their screen.
[09:08] All right, last clean breaker, not last microwave.
[09:11] So, so, so, so good.
[09:12] No nils.
[09:13] Just very recent microwave incidents.
[09:17] So that's one way we could do that one.
[09:20] All right, so comment.
[09:22] Uncomment these ones.
[09:24] All right, next up we have these levels
[09:25] and if you looked at how this is generated,
[09:29] you'd see randomly, your staff manager,
[09:32] director, VPC suite.
[09:33] These are symbols and they're not gonna
[09:36] naturally be sortable.
[09:38] So we can do that ourselves.
[09:39] We could make a hash of numeric values,
[09:41] or we could do like a case statement.
[09:44] So user.level, when,
[09:49] staff then one when manager,
[09:55] then two win director,
[09:58] then three when vp,
[10:01] then four.
[10:02] And finally when you're in the c_suite,
[10:04] you're the highest ranking.
[10:06] So you're then five and then end of course.
[10:10] Okay, so if I run this,
[10:11] it won't work 'cause I forgot a comma.
[10:15] Comma okay, try that again.
[10:19] All right, so that did a thing.
[10:21] But let's check that the sort actually worked first.
[10:27] By isolating to just that case
[10:30] and no it didn't.
[10:33] It's showing staff on top.
[10:34] And that's because one comes before five.
[10:36] So a trick that we can do is we can say negative one times
[10:39] and then the negative five will come up first.
[10:43] And cool these are all now in the c_suite.
[10:45] So that condition works.
[10:46] The final condition we had was about distance.
[10:50] So I already wrote a little plain ole Ruby object
[10:52] called GetsDistance using the geo kit gem.
[10:56] So GetsDistance.new.get user.lat, user.long.
[11:02] And then the break room has a latitude
[11:04] and a longitude as well.
[11:07] And that'll give me a value.
[11:09] I'm gonna again just focus on isolating one thing at a time.
[11:13] If I ran this, it'll blow up.
[11:16] Because additionally,
[11:18] some users may not have a location there.
[11:20] So if there's any nils,
[11:21] we're gonna get a nil.
[11:22] And then nils aren't comparable.
[11:23] And you're now very familiar
[11:25] and expert at this sort by pattern.
[11:26] All right, so we're gonna just do a quick breakover
[11:29] and say minimally distant is what?
[11:36] So we want a very high number
[11:37] to push you to the bottom of the list.
[11:39] And high number would be like float infinity.
[11:41] There we go.
[11:45] Cool.
[11:46] And so if we look at these lat and longs
[11:48] you can see that they're kind of close together.
[11:49] I don't know where the break room is
[11:51] but one presumes, it's near that.
[11:53] All right, so those are all our conditions.
[11:56] Let's uncomment them all and run it.
[11:58] Again, we don't have any tests
[12:00] and all this data constantly keeps changing
[12:02] but it seems pretty right.
[12:04] Okay, so yeah, people who've never cleaned before
[12:07] but are active,
[12:08] those are gonna float to the top.
[12:09] All right, so let's start talking about the Put gem.
[12:14] So first we're going to,
[12:16] let's see, take a look at our
[12:19] readme so it's testdouble/Put.
[12:21] Gem install Put.
[12:22] You put Put in your gem pile.
[12:25] It's three character names.
[12:26] So I got excited.
[12:27] But like the API is pretty straightforward.
[12:29] You have like Put first, Put last, Put ascending
[12:32] but it's not like a top level api.
[12:33] It's meant to pair with sort by.
[12:34] And that's why I think an example is gonna be
[12:36] the best way to show everyone.
[12:37] All right, so let's add Put to our gem file Put
[12:41] and then we're going to bundle.
[12:45] Bundle up.
[12:46] Great require Put.
[12:49] Cool, let's just do one thing at a time.
[12:53] So first of all, we know that active users we wanna put
[12:56] at the front or the top or first.
[12:59] So we're gonna say put first if user.active
[13:03] that's all we're gonna say.
[13:04] Remove that.
[13:05] Now because that's an in line if statement.
[13:09] We need to wrap it in parenthesis
[13:11] so the parser knows what to do with us.
[13:13] We've run that.
[13:14] Good, everyone's active.
[13:16] So that one's right.
[13:18] Now, we actually wanna Put last
[13:20] if you have a mobility accommodation.
[13:22] So we're gonna say Put last
[13:24] if user.accommodations.mobility.
[13:30] Okay.
[13:32] And we can see real quick.
[13:39] Didn't see any mobility.
[13:40] So that's sorted in the right order I think.
[13:43] Next up the break room thing.
[13:45] Now what's nice about Put,
[13:47] is it's nil safe by default.
[13:49] So we don't have to worry about all these nil cases.
[13:51] We can actually just say
[13:52] "Put ascending user.last cleaned break room at".
[13:57] And that would be all we need to do except
[13:58] for the fact that if you've never cleaned the break room
[14:00] before we actually want you,
[14:01] it'll be nil and we want you to be at the top of the list.
[14:04] By default, nils will go to the bottom of the list
[14:06] 'cause usually they just don't matter.
[14:07] But this is the opposite case.
[14:08] So we can say nils first true
[14:10] with this optional keyword argument.
[14:12] All right, so let's whack that.
[14:15] Take a look, see if this seems to work.
[14:19] Yeah, so you can see these relatively distant cleanings
[14:22] followed by nil.
[14:23] So all but seven people had cleaned at some point
[14:27] and then it's back to like 2021, September.
[14:30] Okay, this next case here,
[14:32] we had broken down and kind of computed a duration
[14:35] to get it into a descending order.
[14:36] But we don't have to do that
[14:37] because Put actually will have a descending method
[14:40] and what it does is it's the same as ascending
[14:43] except it'll just like negative-fy the result
[14:46] of the comparison operator of A to B.
[14:49] And so it'll just know that if it's given a time,
[14:54] that the newest time should go on top.
[14:58] So we can say last microwaved fish at,
[15:02] and here we want the nils to go on the bottom
[15:04] 'cause people who've never microwave fish
[15:06] shouldn't be more responsible for cleaning the break room.
[15:09] And so we can just say put descending last microwaved fish
[15:12] and that should work.
[15:14] And we're gonna check it.
[15:16] By just commenting everything out that we got so far.
[15:20] Run that.
[15:21] And you can see we've got some very recent
[15:24] fish microwaving incidents in just a few days ago
[15:27] in September, 2022.
[15:29] All right, now we got this case statement
[15:33] of ranks or levels inside the organization,
[15:36] numerified and then multiplied by negative one.
[15:39] So here we want to have you go descending by rank.
[15:45] So Put descending and then we can actually have
[15:49] the same case statement.
[15:50] We just get rid of that negative one.
[15:55] Now it's a little bit weird
[15:56] looking at a case statement like this,
[15:57] it probably makes sense to extract it
[15:58] or add a method to user,
[16:01] but I think that's fine for now.
[16:03] And we can just...
[16:04] Again, can't hurt to double check,
[16:06] run
[16:09] this and see.
[16:11] yeah, level c_suite.
[16:13] All right.
[16:14] Okay, commenting out this one.
[16:16] So we can take a look at the distance.
[16:17] This should be really easy.
[16:18] We just get rid of the nil check
[16:20] because it handles nils for us.
[16:24] All right, so that's an example of...
[16:28] Maybe you could have done almost all of this
[16:30] in a database order by statement
[16:32] but then this last one would've been difficult
[16:33] with translating levels to numbers.
[16:37] And then this last one might have been very, very difficult
[16:39] unless you have like a post GIS or something
[16:42] in your database to compare the distance between two points.
[16:45] And so we had to do this in Ruby,
[16:48] for example, just to get this distance comparison.
[16:50] And here we are.
[16:51] You can see it still seems to work.
[16:53] All right, so uncommenting this
[16:57] and just sort of taking it all in,
[16:58] and hiding our terminal.
[17:00] You can sort of see like it's way clearer.
[17:02] It's still not beautiful code
[17:04] but if you've seen sort by done before
[17:05] you can kind of see,
[17:06] "Okay, so top top of the list if they're active,
[17:09] last if they've got a mobility impairment,
[17:11] ascending order for who's cleaned the break room
[17:16] least recently, and if they've never,
[17:18] go the to the top and so on and so forth.
[17:21] And so that's roughly how you might use Put
[17:26] to sort a list of stuff based on complex
[17:29] or numerous criteria.
[17:33] And so yeah it's a fun little gem.
[17:35] That's all it does.
[17:36] It just makes sort by blocks like this a little bit clearer
[17:40] but I hope that you'll think of it next time
[17:41] that you gotta sort stuff in Ruby
[17:43] and you got a lot of conditions
[17:45] and you don't want to create a whole bunch reams
[17:47] and reams of objects to do a lot of complex logic.
[17:50] Sort by can do the heavy lifting for you.
[17:52] So I hope you find this useful
[17:54] and if you have any comments, feedback, or questions,
[17:57] feel free to tweet at me, email me or leave a comment.