├── .github └── workflows │ └── main.yml ├── .gitignore ├── .standard.yml ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console ├── rake └── setup ├── lib ├── put.rb └── put │ ├── debug.rb │ ├── nil_ext.rb │ ├── puts_thing.rb │ ├── puts_thing │ ├── anywhere.rb │ ├── ascending.rb │ ├── descending.rb │ ├── first.rb │ ├── in_order.rb │ ├── last.rb │ ├── nil_order.rb │ ├── nils_first.rb │ └── nils_last.rb │ └── version.rb ├── put.gemspec ├── sig └── put.rbs └── test ├── put ├── debug_test.rb └── puts_thing │ ├── anywhere_test.rb │ ├── ascending_test.rb │ ├── nils_first_test.rb │ └── nils_last_test.rb ├── put_test.rb └── test_helper.rb /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '3.1.2' 18 | - '2.6.0' 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Ruby 23 | uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: ${{ matrix.ruby }} 26 | bundler-cache: true 27 | - name: Run the default task 28 | run: bundle exec rake 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | # For available configuration options, see: 2 | # https://github.com/testdouble/standard 3 | ruby_version: 2.6 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.1.0] - 2022-09-22 2 | 3 | - Add Put.nils_first, Put.nils_last 4 | - Add Put.anywhere 5 | 6 | ## [0.0.2] - 2022-09-21 7 | 8 | - Add support for Put.asc, Put.desc, and whether nils go first or last 9 | 10 | ## [0.0.1] - 2022-09-21 11 | 12 | - Initial release 13 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "debug" 6 | gem "rake" 7 | gem "minitest" 8 | gem "standard" 9 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | put (0.1.0) 5 | 6 | GEM 7 | remote: https://rubygems.org/ 8 | specs: 9 | ast (2.4.2) 10 | debug (1.6.2) 11 | irb (>= 1.3.6) 12 | reline (>= 0.3.1) 13 | io-console (0.5.11) 14 | irb (1.4.1) 15 | reline (>= 0.3.0) 16 | json (2.6.2) 17 | minitest (5.16.3) 18 | parallel (1.22.1) 19 | parser (3.1.2.1) 20 | ast (~> 2.4.1) 21 | rainbow (3.1.1) 22 | rake (13.0.6) 23 | regexp_parser (2.5.0) 24 | reline (0.3.1) 25 | io-console (~> 0.5) 26 | rexml (3.2.5) 27 | rubocop (1.35.1) 28 | json (~> 2.3) 29 | parallel (~> 1.10) 30 | parser (>= 3.1.2.1) 31 | rainbow (>= 2.2.2, < 4.0) 32 | regexp_parser (>= 1.8, < 3.0) 33 | rexml (>= 3.2.5, < 4.0) 34 | rubocop-ast (>= 1.20.1, < 2.0) 35 | ruby-progressbar (~> 1.7) 36 | unicode-display_width (>= 1.4.0, < 3.0) 37 | rubocop-ast (1.21.0) 38 | parser (>= 3.1.1.0) 39 | rubocop-performance (1.14.3) 40 | rubocop (>= 1.7.0, < 2.0) 41 | rubocop-ast (>= 0.4.0) 42 | ruby-progressbar (1.11.0) 43 | standard (1.16.1) 44 | rubocop (= 1.35.1) 45 | rubocop-performance (= 1.14.3) 46 | unicode-display_width (2.3.0) 47 | 48 | PLATFORMS 49 | arm64-darwin-21 50 | arm64-darwin-22 51 | x86_64-linux 52 | 53 | DEPENDENCIES 54 | debug 55 | minitest 56 | put! 57 | rake 58 | standard 59 | 60 | BUNDLED WITH 61 | 2.3.22 62 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Test Double, Inc. 4 | Portions Copyright 2022 Matthew Draper (https://github.com/matthewd) 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Put puts your objects in order 💎 2 | 3 | Put pairs with 4 | [Enumerable#sort_by](https://ruby-doc.org/core-3.1.2/Enumerable.html#method-i-sort_by) 5 | to provide a more expressive, fault-tolerant, and configurable approach to 6 | sorting Ruby objects with multiple criteria. [Here's a screencast & short blog 7 | post](https://blog.testdouble.com/talks/2022-09-28-a-better-way-to-sort-ruby-objects/) 8 | that we put out, in case you're interested. 9 | 10 | ## Put "put" in your Gemfile 11 | 12 | You've probably already put a few gems in there, so why not put Put, too: 13 | 14 | ```ruby 15 | gem "put" 16 | ``` 17 | 18 | Of course after you push Put, your colleagues will wonder why you put Put there. 19 | 20 | ## Before you tell me where to put it 21 | 22 | A neat trick when applying complex sorting rules to a collection is to map them 23 | to an array of arrays of comparable values in priority order. It's a common 24 | approach (and a special subtype of what's called a [Schwartzian 25 | transform](https://en.wikipedia.org/wiki/Schwartzian_transform)), but the 26 | pattern doesn't have a widely-accepted name yet, so let's use code to explain. 27 | 28 | Suppose you have some people: 29 | 30 | ```ruby 31 | Person = Struct.new(:name, :age, :rubyist?, keyword_init: true) 32 | 33 | people = [ 34 | Person.new(name: "Tam", age: 22), 35 | Person.new(name: "Zak", age: 33), 36 | Person.new(name: "Axe", age: 33), 37 | Person.new(name: "Qin", age: 18, rubyist?: true), 38 | Person.new(name: "Zoe", age: 28, rubyist?: true) 39 | ] 40 | ``` 41 | 42 | And you want to sort these people in the following priority order: 43 | 44 | 1. Put any Rubyists at the _top_ of the list, as is right and good 45 | 2. If both are (or are not) Rubyists, break the tie by sorting by age descending 46 | 3. Finally, break any remaining ties by sorting by name ascending 47 | 48 | Here's what the aforementioned pattern to accomplish this usually looks like 49 | using 50 | [Enumerable#sort_by](https://ruby-doc.org/core-3.1.2/Enumerable.html#method-i-sort_by): 51 | 52 | ```ruby 53 | people.sort_by { |person| 54 | [ 55 | person.rubyist? ? 0 : 1, 56 | person.age * -1, 57 | person.name 58 | ] 59 | } # => Zoe, Qin, Axe, Zak, Tam 60 | ``` 61 | 62 | The above will return everyone in the right order. This has a few drawbacks, 63 | though: 64 | 65 | * Unless you're already familiar with this pattern that nobody's bothered to 66 | give a name before, this code isn't very expressive. As a result, each line 67 | is almost begging for a code comment above it to explain its intent 68 | * Ternary operators are confusing, especially with predicate methods like 69 | `rubyist?` and especially when returning [magic 70 | number](https://en.wikipedia.org/wiki/Magic_number_(programming))'s like `1` and 71 | `0`. 72 | * Any `nil` values will result in a bad time. If a person's `age` is nil, you'll 73 | get "_undefined method '*' for nil:NilClass_" `NoMethodError` 74 | * Relatedly, if any two items aren't comparable (i.e. `<=>` returns nil), you'll 75 | be greeted with an inscrutable `ArgumentError` that just says "_comparison of 76 | Array with Array failed_" 77 | 78 | Here's the same code example if you put Put in there: 79 | 80 | ```ruby 81 | people.sort_by { |person| 82 | [ 83 | (Put.first if person.rubyist?), 84 | Put.desc(person.age), 85 | Put.asc(person.name) 86 | ] 87 | } # => Zoe, Qin, Axe, Zak, Tam 88 | ``` 89 | 90 | The Put gem solves every one of the above issues: 91 | 92 | * Put's methods have actual names. In fact, let's just call this the "Put 93 | pattern" while we're at it 94 | * No ternaries necessary 95 | * It's quite `nil` friendly 96 | * It ships with a `Put.debug` method that helps you introspect those 97 | impenetrable `ArgumentError` messages whenever any two values turn out not to 98 | be comparable 99 | 100 | After reading this, your teammates will be glad they put you in charge of 101 | putting gems like Put in the Gemfile. 102 | 103 | ## When you Put it that way 104 | 105 | Put's API is short and sweet. In fact, you've already put up with most of it. 106 | 107 | ### Put.first 108 | 109 | When a particular condition indicates an item should go to the top of a list, 110 | you'll want to designate a position in your mapped `sort_by` arrays to return 111 | either `Put.first` or `nil`, like this: 112 | 113 | ```ruby 114 | [42, 12, 65, 99, 49].sort_by { |n| 115 | [(Put.first if n.odd?)] 116 | } # => 65, 99, 49, 42, 12 117 | ``` 118 | 119 | ### Put.last 120 | 121 | When a sort criteria should go to the bottom of the list, you can do the same 122 | sort of conditional expression with `Put.last`: 123 | 124 | ```ruby 125 | %w[Jin drinks Gin on Gym day].sort_by { |s| 126 | [(Put.last unless s.match?(/[A-Z]/))] 127 | } # => ["Jin", "Gin", "Gym", "drinks", "on", "day"] 128 | ``` 129 | 130 | ### Put.asc(value, nils_first: false) 131 | 132 | The `Put.asc` method provides a nil-safe way to sort a value in ascending order: 133 | 134 | ```ruby 135 | %w[The quick brown fox].sort_by { |s| 136 | [Put.asc(s)] 137 | } # => ["The", "brown", "fox", "quick"] 138 | ``` 139 | 140 | It also supports an optional `nils_first` keyword argument that defaults to 141 | false (translation: nils are sorted last by default), which looks like this: 142 | 143 | ```ruby 144 | [3, nil, 1, 5].sort_by { |n| 145 | [Put.asc(n, nils_first: true)] 146 | } # => [nil, 1, 3, 5] 147 | ``` 148 | 149 | ### Put.desc(value, nils_first: false) 150 | 151 | The opposite of `Put.asc` is `Put.desc`, and it works as you might suspect: 152 | 153 | ```ruby 154 | %w[Aardvark Zebra].sort_by { |s| 155 | [Put.desc(s)] 156 | } # => ["Zebra", "Aardvark"] 157 | ``` 158 | 159 | And also like `Put.asc`, `Put.desc` has an optional `nils_first` keyword 160 | argument when you want nils on top: 161 | 162 | ```ruby 163 | [1, nil, 2, 3].sort_by { |n| 164 | [Put.desc(n, nils_first: true)] 165 | } # => [nil, 3, 2, 1] 166 | ``` 167 | 168 | ### Put.anywhere 169 | 170 | You're sorting stuff, so naturally _order matters_. But when building a compound 171 | `sort_by` expression, order matters less as you add more and more tiebreaking 172 | criteria. In fact, sometimes shuffling items is the more appropriate than 173 | leaving things in their original order. Enter `Put.anywhere`, which can be 174 | called without any argument at any index in the mapped sorting array: 175 | 176 | ```ruby 177 | [1, 3, 4, 7, 8, 9].sort_by { |n| 178 | [ 179 | (Put.first if n.even?), 180 | Put.anywhere 181 | ] 182 | } # => [8, 4, 1, 7, 9, 3] 183 | ``` 184 | 185 | ### Put.nils_first(value) 186 | 187 | If you're sorting items and you know some not-comparable `nil` values are going 188 | to appear, you can put all the nils on top with `Put.nil_first(value)`. Note 189 | that _unlike_ `Put.asc` and `Put.desc`, it won't actually sort the values—it'll 190 | just pull all the nils up! 191 | 192 | ```ruby 193 | [:fun, :stuff, nil, :here].sort_by { |val| 194 | [Put.nils_first(val)] 195 | } # => [nil, :fun, :stuff, :here] 196 | ``` 197 | 198 | ### Put.nils_last(value) 199 | 200 | As you might be able to guess, `Put.nils_last` puts the nils last: 201 | 202 | ```ruby 203 | [:every, nil, :counts].sort_by { |val| 204 | [Put.nils_last(val)] 205 | } # => [:every, :counts, nil] 206 | ``` 207 | 208 | ### Put.debug(sorting_arrays) 209 | 210 | If you see "comparison of Array with Array failed" and you don't have any idea 211 | what is going on, try debugging by changing `sort_by` to `map` and passing it 212 | to `Put.debug`. 213 | 214 | For an interactive example of how to debug this issue with `Put.debug`, take a 215 | look [at this test case](/test/put_test.rb#L53-L98). 216 | 217 | ## Put your hands together! 👏 218 | 219 | Many thanks to [Matt Jones](https://github.com/al2o3cr) and [Matthew 220 | Draper](https://github.com/matthewd) for answering a bunch of obscure questions 221 | about comparisons in Ruby and implementing the initial prototype, respectively. 222 | 👏👏👏 223 | 224 | ## Code of Conduct 225 | 226 | This project follows Test Double's [code of 227 | conduct](https://testdouble.com/code-of-conduct) for all community interactions, 228 | including (but not limited to) one-on-one communications, public posts/comments, 229 | code reviews, pull requests, and GitHub issues. If violations occur, Test Double 230 | will take any action they deem appropriate for the infraction, up to and 231 | including blocking a user from the organization's repositories. 232 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList["test/**/*_test.rb"] 8 | end 9 | 10 | require "standard/rake" 11 | 12 | task default: %i[test standard] 13 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "debug" 5 | require "put" 6 | 7 | # README example code 8 | Person = Struct.new(:name, :age, :rubyist?, keyword_init: true) 9 | 10 | people = [ 11 | Person.new(name: "Tam", age: 22), 12 | Person.new(name: "Zak", age: 33), 13 | Person.new(name: "Axe", age: 33), 14 | Person.new(name: "Qin", age: 18, rubyist?: true), 15 | Person.new(name: "Zoe", age: 28, rubyist?: true) 16 | ] 17 | 18 | puts people.sort_by { |person| 19 | [ 20 | person.rubyist? ? 0 : 1, 21 | person.age * -1, 22 | person.name 23 | ] 24 | } 25 | 26 | puts people.sort_by { |person| 27 | [ 28 | (Put.first if person.rubyist?), 29 | Put.desc(person.age), 30 | Put.asc(person.name) 31 | ] 32 | } 33 | 34 | puts [42, 12, 65, 99, 49].sort_by { |n| 35 | [(Put.first if n.odd?)] 36 | } 37 | 38 | puts %w[Jin drinks Gin on Gym day].sort_by { |s| 39 | [(Put.last unless s.match?(/[A-Z]/))] 40 | } # => ["Jin", "Gin", "Gym", "drinks", "on", "day"] 41 | 42 | puts %w[The quick brown fox].sort_by { |s| 43 | [Put.asc(s)] 44 | } # => ["The", "brown", "fox", "quick"] 45 | 46 | puts [3, nil, 1, 5].sort_by { |n| 47 | [Put.asc(n, nils_first: true)] 48 | } # => [nil, 1, 3, 5] 49 | 50 | puts %w[Aardvark Zebra].sort_by { |s| 51 | [Put.desc(s)] 52 | } # => ["Zebra", "Aardvark"] 53 | 54 | puts [1, nil, 2, 3].sort_by { |n| 55 | [Put.desc(n, nils_first: true)] 56 | } # => [nil, 3, 2, 1] 57 | 58 | puts [1, 3, 4, 7, 8, 9].sort_by { |n| 59 | [ 60 | (Put.first if n.even?), 61 | Put.anywhere 62 | ] 63 | } # => [8, 4, 1, 7, 9, 3] 64 | 65 | puts [:fun, :stuff, nil, :here].sort_by { |val| 66 | [Put.nils_first(val)] 67 | } # => [nil, :fun, :stuff, :here] 68 | 69 | puts [:every, nil, :counts].sort_by { |val| 70 | [Put.nils_last(val)] 71 | } # => [:every, :counts, nil] 72 | # binding.debugger 73 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rake' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rake", "rake") 28 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/put.rb: -------------------------------------------------------------------------------- 1 | require_relative "put/debug" 2 | require_relative "put/version" 3 | require_relative "put/nil_ext" 4 | require_relative "put/puts_thing" 5 | require_relative "put/puts_thing/anywhere" 6 | require_relative "put/puts_thing/first" 7 | require_relative "put/puts_thing/last" 8 | require_relative "put/puts_thing/in_order" 9 | require_relative "put/puts_thing/ascending" 10 | require_relative "put/puts_thing/descending" 11 | require_relative "put/puts_thing/nil_order" 12 | require_relative "put/puts_thing/nils_first" 13 | require_relative "put/puts_thing/nils_last" 14 | 15 | module Put 16 | def self.first 17 | @@first ||= PutsThing::First.new.freeze 18 | end 19 | 20 | def self.last 21 | @@last ||= PutsThing::Last.new.freeze 22 | end 23 | 24 | def self.desc(value, nils_first: false) 25 | PutsThing::Descending.new(value, nils_first: nils_first) 26 | end 27 | 28 | def self.asc(value, nils_first: false) 29 | PutsThing::Ascending.new(value, nils_first: nils_first) 30 | end 31 | 32 | def self.nils_first(value) 33 | PutsThing::NilsFirst.new(value) 34 | end 35 | 36 | def self.nils_last(value) 37 | PutsThing::NilsLast.new(value) 38 | end 39 | 40 | def self.anywhere(seed: nil) 41 | PutsThing::Anywhere.new(seed) 42 | end 43 | 44 | def self.debug(sorting_arrays) 45 | Debug.new.call(sorting_arrays) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/put/debug.rb: -------------------------------------------------------------------------------- 1 | module Put 2 | class Debug 3 | Result = Struct.new(:success?, :incomparables, keyword_init: true) 4 | Incomparable = Struct.new( 5 | :sorting_index, :left, :left_index, :left_value, 6 | :right, :right_index, :right_value, keyword_init: true 7 | ) { 8 | def inspect 9 | both_puts_things = left.is_a?(PutsThing) && right.is_a?(PutsThing) 10 | left_desc = (both_puts_things ? left_value : left).inspect 11 | right_desc = (both_puts_things ? right_value : right).inspect 12 | "Sorting comparator at index #{sorting_index} failed, because items at indices #{left_index} and #{right_index} were not comparable. Their values were `#{left_desc}' and `#{right_desc}', respectively." 13 | end 14 | } 15 | 16 | def call(sorting_arrays) 17 | sorting_arrays.sort 18 | Result.new(success?: true, incomparables: []) 19 | rescue ArgumentError 20 | # TODO this is O(n^lol) 21 | incomparables = sorting_arrays.transpose.map.with_index { |comparables, sorting_index| 22 | comparables.map.with_index { |comparable, comparable_index| 23 | comparables.map.with_index { |other, other_index| 24 | next if comparable_index == other_index 25 | if (comparable <=> other).nil? 26 | Incomparable.new( 27 | sorting_index: sorting_index, 28 | left: comparable, 29 | left_index: comparable_index, 30 | left_value: (comparable.value if comparable.is_a?(PutsThing)), 31 | right: other, 32 | right_index: other_index, 33 | right_value: (other.value if other.is_a?(PutsThing)) 34 | ) 35 | end 36 | } 37 | } 38 | }.flatten.compact.uniq { |inc| 39 | # Remove dupes where two items are incomparable in both <=> directions: 40 | [inc.sorting_index] + [inc.left_index, inc.right_index].sort 41 | } 42 | Result.new(success?: false, incomparables: incomparables) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/put/nil_ext.rb: -------------------------------------------------------------------------------- 1 | module Put 2 | module NilExtension 3 | def <=>(other) 4 | if ::Put::PutsThing === other 5 | -(other <=> self) 6 | else 7 | super 8 | end 9 | end 10 | end 11 | ::NilClass.prepend(NilExtension) 12 | end 13 | -------------------------------------------------------------------------------- /lib/put/puts_thing.rb: -------------------------------------------------------------------------------- 1 | module Put 2 | class PutsThing 3 | def <=>(other) 4 | if value.nil? && (other.nil? || other&.value.nil?) 5 | 0 6 | elsif value.nil? 7 | nils_first? ? -1 : 1 8 | elsif other.nil? || other&.value.nil? 9 | nils_first? ? 1 : -1 10 | elsif other && !other.reverse? 11 | value <=> other.value 12 | elsif other&.reverse? 13 | other.value <=> value 14 | else 15 | value <=> 0 16 | end 17 | end 18 | 19 | def reverse? 20 | false 21 | end 22 | 23 | def nils_first? 24 | false 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/put/puts_thing/anywhere.rb: -------------------------------------------------------------------------------- 1 | module Put 2 | class PutsThing 3 | class Anywhere < PutsThing 4 | def initialize(seed) 5 | @random = seed.nil? ? Random.new : Random.new(seed) 6 | end 7 | 8 | def value 9 | @random.rand 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/put/puts_thing/ascending.rb: -------------------------------------------------------------------------------- 1 | module Put 2 | class PutsThing 3 | class Ascending < InOrder 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/put/puts_thing/descending.rb: -------------------------------------------------------------------------------- 1 | module Put 2 | class PutsThing 3 | class Descending < InOrder 4 | def reverse? 5 | true 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/put/puts_thing/first.rb: -------------------------------------------------------------------------------- 1 | module Put 2 | class PutsThing 3 | class First < PutsThing 4 | def value 5 | -Float::INFINITY 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/put/puts_thing/in_order.rb: -------------------------------------------------------------------------------- 1 | module Put 2 | class PutsThing 3 | class InOrder < PutsThing 4 | def initialize(value, nils_first:) 5 | @value = value 6 | @nils_first = nils_first 7 | end 8 | 9 | attr_reader :value 10 | 11 | def nils_first? 12 | @nils_first 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/put/puts_thing/last.rb: -------------------------------------------------------------------------------- 1 | module Put 2 | class PutsThing 3 | class Last < PutsThing 4 | def value 5 | Float::INFINITY 6 | end 7 | 8 | def nils_first? 9 | true 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/put/puts_thing/nil_order.rb: -------------------------------------------------------------------------------- 1 | module Put 2 | class PutsThing 3 | class NilOrder < PutsThing 4 | def initialize(value) 5 | @value = value 6 | end 7 | 8 | def value 9 | if @value.nil? 10 | nil 11 | else 12 | 0 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/put/puts_thing/nils_first.rb: -------------------------------------------------------------------------------- 1 | module Put 2 | class PutsThing 3 | class NilsFirst < NilOrder 4 | def nils_first? 5 | true 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/put/puts_thing/nils_last.rb: -------------------------------------------------------------------------------- 1 | module Put 2 | class PutsThing 3 | class NilsLast < NilOrder 4 | def nils_first? 5 | false 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/put/version.rb: -------------------------------------------------------------------------------- 1 | module Put 2 | VERSION = "0.1.0" 3 | end 4 | -------------------------------------------------------------------------------- /put.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/put/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "put" 5 | spec.version = Put::VERSION 6 | spec.authors = ["Justin Searls"] 7 | spec.email = ["searls@gmail.com"] 8 | 9 | spec.summary = "Put helps you write prioritized, multi-variate sort_by blocks" 10 | spec.homepage = "https://github.com/testdouble/put" 11 | spec.license = "MIT" 12 | spec.required_ruby_version = ">= 2.6.0" 13 | 14 | spec.metadata["homepage_uri"] = spec.homepage 15 | spec.metadata["source_code_uri"] = spec.homepage 16 | spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" 17 | 18 | # Specify which files should be added to the gem when it is released. 19 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 20 | spec.files = Dir.chdir(__dir__) do 21 | `git ls-files -z`.split("\x0").reject do |f| 22 | (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) 23 | end 24 | end 25 | spec.bindir = "exe" 26 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 27 | spec.require_paths = ["lib"] 28 | end 29 | -------------------------------------------------------------------------------- /sig/put.rbs: -------------------------------------------------------------------------------- 1 | module Put 2 | VERSION: String 3 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 4 | end 5 | -------------------------------------------------------------------------------- /test/put/debug_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Put 4 | class DebugTest < Minitest::Test 5 | Dingus = Struct.new(:name, keyword_init: true) 6 | 7 | def setup 8 | @subject = Debug.new 9 | end 10 | 11 | def test_sortable_stuff_doesnt_fail 12 | dinguses = [Dingus.new(name: "Jerry"), Dingus.new(name: "Justin")] 13 | 14 | result = @subject.call(dinguses.map { |dingus| 15 | [Put.asc(dingus.name)] 16 | }) 17 | 18 | assert result.success? 19 | assert_equal 0, result.incomparables.size 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/put/puts_thing/anywhere_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Put 4 | class PutsThing 5 | class AnywhereTest < Minitest::Test 6 | def test_it_seems_to_shuffle_stuff 7 | stuff = [1, 2, 3, 4, 5] 8 | 9 | result_1 = stuff.sort_by { |thing| 10 | [Put.anywhere] 11 | } 12 | result_2 = stuff.sort_by { |thing| 13 | [Put.anywhere] 14 | } 15 | 16 | refute_equal [1, 2, 3, 4, 5], result_1 17 | refute_equal result_1, result_2 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/put/puts_thing/ascending_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Put 4 | class PutsThing 5 | class AscendingTest < Minitest::Test 6 | def test_asc_nils_first 7 | assert_equal([ 8 | Put.asc("B", nils_first: true), 9 | Put.asc(nil, nils_first: true), 10 | Put.asc("A", nils_first: true), 11 | Put.asc(nil, nils_first: true) 12 | ].sort.map(&:value), [ 13 | nil, nil, "A", "B" 14 | ]) 15 | end 16 | 17 | def test_asc_nils_last 18 | assert_equal([ 19 | nil, 20 | Put.asc(45, nils_first: false), 21 | nil, 22 | Put.asc(nil, nils_first: false), 23 | nil, 24 | Put.asc(33, nils_first: false), 25 | nil, 26 | Put.asc(nil, nils_first: false) 27 | ].sort.map { |o| o.respond_to?(:value) ? o.value : o }, [ 28 | 33, 45, nil, nil, nil, nil, nil, nil 29 | ]) 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/put/puts_thing/nils_first_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Put 4 | class PutsThing 5 | class NilsFirstTest < Minitest::Test 6 | def test_the_nils_go_first 7 | result = ["B", nil, "A", nil].sort_by { |thing| 8 | [Put.nils_first(thing)] 9 | } 10 | 11 | assert_equal [nil, nil, "B", "A"], result 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/put/puts_thing/nils_last_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Put 4 | class PutsThing 5 | class NilsLastTest < Minitest::Test 6 | def test_the_nils_go_last 7 | result = ["B", nil, "A", nil].sort_by { |thing| 8 | [Put.nils_last(thing)] 9 | } 10 | 11 | assert_equal ["B", "A", nil, nil], result 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/put_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class PutTest < Minitest::Test 4 | Golfer = Struct.new(:name, :age, :handicap, :member, keyword_init: true) { 5 | def minor? 6 | return if age.nil? 7 | 8 | age < 21 9 | end 10 | } 11 | 12 | def test_put 13 | golfers = [ 14 | noah = Golfer.new(name: "Noah", age: 13, handicap: 18, member: true), 15 | eve = Golfer.new(name: "Eve", age: 42, handicap: 8, member: true), 16 | nate = Golfer.new(name: "Nate", age: 31, handicap: 12, member: false), 17 | logan = Golfer.new(name: "Logan", age: 31, handicap: 14, member: false), 18 | tam = Golfer.new(name: "Tam", age: 31, handicap: nil, member: false), 19 | tom = Golfer.new(name: "Tom", age: 31, handicap: 33, member: false), 20 | noah2 = Golfer.new(name: "Noah", age: 13, handicap: 16, member: true), 21 | harper = Golfer.new(name: "Harper", age: 32, handicap: 22, member: false), 22 | nill = Golfer.new(name: nil, age: 31, handicap: 18, member: false), 23 | avery = Golfer.new(name: "Avery", age: nil, handicap: 0, member: true) 24 | ] 25 | 26 | result = golfers.sort_by { |golfer| 27 | [ 28 | (Put.last if golfer.minor?), 29 | (Put.first if golfer.member), 30 | Put.desc(golfer.age, nils_first: true), 31 | Put.nils_last(golfer.handicap), 32 | Put.asc(golfer.name), 33 | Put.anywhere 34 | ] 35 | } 36 | 37 | assert_equal([ 38 | avery, 39 | eve, 40 | harper, 41 | logan, 42 | nate, 43 | tom, 44 | nill, 45 | tam 46 | ], result.first(8)) 47 | assert_includes result.last(2), noah 48 | assert_includes result.last(2), noah2 49 | end 50 | 51 | Bot = Struct.new(:model, :age, keyword_init: true) 52 | 53 | def test_debug 54 | # Given you have heterogeneous, not comparable things: 55 | bots = [ 56 | Bot.new(model: "X", age: 1), 57 | Bot.new(model: "Y", age: 2), 58 | Bot.new(model: 3, age: 2) 59 | ] 60 | # When you try to compare them anyway: 61 | expected_error = assert_raises do 62 | bots.sort_by { |bot| 63 | [ 64 | Put.desc(bot.age), 65 | Put.asc(bot.model) 66 | ] 67 | } 68 | end 69 | # Then you'll get this unhelpful, hard to debug error 70 | assert_kind_of ArgumentError, expected_error 71 | assert_equal "comparison of Array with Array failed", expected_error.message 72 | 73 | # When debugging this, you can change the `sort_by` to a `map` 74 | bot_sorts = bots.map { |bot| 75 | [ 76 | Put.desc(bot.age), 77 | Put.asc(bot.model) 78 | ] 79 | } 80 | # And pass the results to Put.debug 81 | result = Put.debug(bot_sorts) 82 | # Then see if sort_by would have raised an error: 83 | refute result.success? 84 | assert_equal 2, result.incomparables.size 85 | # And you can see which items were incomparable: 86 | x_and_3, y_and_3 = result.incomparables 87 | # And comparaison failed in the second item in the sorting array (i.e. Put.asc(bot.model) failed) 88 | assert_equal 1, x_and_3.sorting_index 89 | # And specifically first and third elements aren't comparable in this way: 90 | assert_equal 0, x_and_3.left_index 91 | assert_equal 2, x_and_3.right_index 92 | # And their values are "X" and 3 93 | assert_equal "X", x_and_3.left_value 94 | assert_equal 3, x_and_3.right_value 95 | assert_equal "Sorting comparator at index 1 failed, because items at indices 0 and 2 were not comparable. Their values were `\"X\"' and `3', respectively.", x_and_3.inspect 96 | # Same goes for the comparison of "Y" and 3: 97 | assert_equal "Sorting comparator at index 1 failed, because items at indices 1 and 2 were not comparable. Their values were `\"Y\"' and `3', respectively.", y_and_3.inspect 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 2 | require "debug" 3 | require "put" 4 | 5 | require "minitest/autorun" 6 | --------------------------------------------------------------------------------