├── .formatter.exs ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── benchmarks ├── max.exs ├── max.results.txt ├── max_by.exs ├── max_by.results.txt ├── sort.exs ├── sort.results.txt ├── sort_by.exs └── sort_by.results.txt ├── lib ├── cmp.ex └── cmp │ ├── comparable.ex │ ├── type_error.ex │ └── util.ex ├── mix.exs ├── mix.lock └── test ├── cmp └── comparable_test.exs ├── cmp_prop_test.exs ├── cmp_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 8 | env: 9 | MIX_ENV: test 10 | strategy: 11 | matrix: 12 | include: 13 | - elixir: "1.13.x" 14 | otp: "24" 15 | - elixir: "1.14.x" 16 | otp: "25" 17 | - elixir: "1.16.x" 18 | otp: "26" 19 | - elixir: "1.17.0-rc.0" 20 | otp: "27" 21 | steps: 22 | - uses: actions/checkout@v2 23 | - uses: erlef/setup-beam@v1 24 | with: 25 | otp-version: ${{matrix.otp}} 26 | elixir-version: ${{matrix.elixir}} 27 | - name: Install Dependencies 28 | run: mix deps.get 29 | - name: Check compile warnings 30 | run: mix compile --warnings-as-errors 31 | - name: Check format 32 | run: mix format --check-formatted 33 | - name: Unit tests 34 | run: mix test.unit 35 | - name: Property-based tests 36 | run: PROP_TEST_RUNTIME=30000 mix test.prop 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | cmp-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Dev 4 | 5 | ## v0.1.3 (2024-05-04) 6 | 7 | - Update `:ex_doc` 8 | 9 | ## v0.1.2 (2023-03-17) 10 | 11 | - Add `Cmp.sort_by/2` 12 | - Add `Cmp.min_by/2` and `Cmp.max_by/2` 13 | 14 | ## v0.1.1 (2023-03-05) 15 | 16 | - Fix broken link in doc 17 | 18 | ## v0.1.0 (2023-03-04) 19 | 20 | - Initial release 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Sabiwara 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cmp 2 | 3 | [![Hex Version](https://img.shields.io/hexpm/v/cmp.svg)](https://hex.pm/packages/cmp) 4 | [![docs](https://img.shields.io/badge/docs-hexpm-blue.svg)](https://hexdocs.pm/cmp/) 5 | [![CI](https://github.com/sabiwara/cmp/workflows/CI/badge.svg)](https://github.com/sabiwara/cmp/actions?query=workflow%3ACI) 6 | 7 | Semantic comparison and sorting for Elixir. 8 | 9 | ## Why `Cmp`? 10 | 11 | The built-in comparison operators as well as functions like `Enum.sort/2` or 12 | `Enum.max/1` are based on Erlang's term ordering and suffer two issues, which 13 | require attention and might lead to unexpected behaviors or bugs: 14 | 15 | ### 1. Structural comparisons 16 | 17 | Built-ins use 18 | [structural comparison over semantic comparison](https://hexdocs.pm/elixir/Kernel.html#module-structural-comparison): 19 | 20 | ```elixir 21 | iex> ~D[2020-03-02] > ~D[2019-06-06] 22 | false 23 | 24 | iex> Enum.sort([~D[2019-01-01], ~D[2020-03-02], ~D[2019-06-06]]) 25 | [~D[2019-01-01], ~D[2020-03-02], ~D[2019-06-06]] 26 | ``` 27 | 28 | Semantic comparison is available but not straightforward: 29 | 30 | ```elixir 31 | iex> Date.compare(~D[2019-01-01], ~D[2020-03-02]) 32 | :lt 33 | 34 | iex> Enum.sort([~D[2019-01-01], ~D[2020-03-02], ~D[2019-06-06]], Date) 35 | [~D[2019-01-01], ~D[2019-06-06], ~D[2020-03-02]] 36 | ``` 37 | 38 | `Cmp` does the right thing out of the box: 39 | 40 | ```elixir 41 | iex> Cmp.gt?(~D[2020-03-02], ~D[2019-06-06]) 42 | true 43 | 44 | iex> Cmp.sort([~D[2019-01-01], ~D[2020-03-02], ~D[2019-06-06]]) 45 | [~D[2019-01-01], ~D[2019-06-06], ~D[2020-03-02]] 46 | ``` 47 | 48 | ### 2. Weakly typed 49 | 50 | Built-in comparators accept any set of operands: 51 | 52 | ```elixir 53 | iex> 2 < "1" 54 | true 55 | 56 | iex> 0 < true 57 | true 58 | 59 | iex> false < nil 60 | true 61 | ``` 62 | 63 | `Cmp` will only compare compatible elements or raise a `Cmp.TypeError`: 64 | 65 | ```elixir 66 | iex> Cmp.lte?(1, 1.0) 67 | true 68 | 69 | iex> Cmp.lte?(2, "1") 70 | ** (Cmp.TypeError) Failed to compare incompatible types - left: 2, right: "1" 71 | ``` 72 | 73 | ## Installation 74 | 75 | `Cmp` can be installed by adding `cmp` to your list of dependencies in 76 | `mix.exs`: 77 | 78 | ```elixir 79 | def deps do 80 | [ 81 | {:cmp, "~> 0.1.3"} 82 | ] 83 | end 84 | ``` 85 | 86 | The documentation can be found at 87 | [https://hexdocs.pm/cmp](https://hexdocs.pm/cmp). 88 | 89 | ## Design goals 90 | 91 | - Fast and well-optimized - the overhead should be quite small over built-in 92 | equivalents. See the `benchmarks/` folder for more details. 93 | - No need to require macros, plain functions 94 | - Easily extensible through the `Cmp.Comparable` protocol 95 | - Robust and well-tested (both unit and property-based) 96 | 97 | Supporting comparisons between non-homogeneous types such as mixed `Decimal` and 98 | built-in numbers for instance is a non-goal. This limitation is a necessary 99 | trade-off in order to ensure the points above. Use the `Decimal` library 100 | directly if you need this. 101 | 102 | ## Copyright and License 103 | 104 | Cmp is licensed under the [MIT License](LICENSE.md). 105 | -------------------------------------------------------------------------------- /benchmarks/max.exs: -------------------------------------------------------------------------------- 1 | list = Enum.shuffle(1..1000) 2 | dates = Enum.map(list, &Date.add(~D[2020-01-02], &1)) 3 | set = MapSet.new(list) 4 | 5 | Benchee.run( 6 | %{ 7 | "Enum (list)" => fn -> Enum.max(list) end, 8 | "Cmp (list)" => fn -> Cmp.max(list) end, 9 | "Enum (dates)" => fn -> Enum.max(dates, Date) end, 10 | "Cmp (dates)" => fn -> Cmp.max(dates) end, 11 | "Enum (set)" => fn -> Enum.max(set) end, 12 | "Cmp (set)" => fn -> Cmp.max(set) end, 13 | }, 14 | time: 2 15 | ) 16 | -------------------------------------------------------------------------------- /benchmarks/max.results.txt: -------------------------------------------------------------------------------- 1 | Operating System: macOS 2 | CPU Information: Apple M1 3 | Number of Available Cores: 8 4 | Available memory: 16 GB 5 | Elixir 1.14.2 6 | Erlang 25.0 7 | 8 | Benchmark suite executing with the following configuration: 9 | warmup: 2 s 10 | time: 2 s 11 | memory time: 0 ns 12 | reduction time: 0 ns 13 | parallel: 1 14 | inputs: none specified 15 | Estimated total run time: 24 s 16 | 17 | Benchmarking Cmp (dates) ... 18 | Benchmarking Cmp (list) ... 19 | Benchmarking Cmp (set) ... 20 | Benchmarking Enum (dates) ... 21 | Benchmarking Enum (list) ... 22 | Benchmarking Enum (set) ... 23 | 24 | Name ips average deviation median 99th % 25 | Enum (list) 572.55 K 1.75 μs ±15.77% 1.71 μs 1.92 μs 26 | Cmp (list) 567.45 K 1.76 μs ±17.48% 1.75 μs 1.92 μs 27 | Cmp (set) 45.17 K 22.14 μs ±13.81% 20.42 μs 27.50 μs 28 | Enum (set) 44.50 K 22.47 μs ±13.02% 20.88 μs 26.33 μs 29 | Enum (dates) 34.88 K 28.67 μs ±3.90% 28.63 μs 29.08 μs 30 | Cmp (dates) 25.95 K 38.53 μs ±12.39% 36.75 μs 50.75 μs 31 | 32 | Comparison: 33 | Enum (list) 572.55 K 34 | Cmp (list) 567.45 K - 1.01x slower +0.0157 μs 35 | Cmp (set) 45.17 K - 12.68x slower +20.39 μs 36 | Enum (set) 44.50 K - 12.87x slower +20.73 μs 37 | Enum (dates) 34.88 K - 16.41x slower +26.92 μs 38 | Cmp (dates) 25.95 K - 22.06x slower +36.79 μs 39 | -------------------------------------------------------------------------------- /benchmarks/max_by.exs: -------------------------------------------------------------------------------- 1 | list = Enum.shuffle(1..1000) |> Enum.map(&[&1]) 2 | dates = Enum.map(list, fn [i] -> [Date.add(~D[2020-01-02], i)] end) 3 | set = MapSet.new(list) 4 | 5 | Benchee.run( 6 | %{ 7 | "Enum (list)" => fn -> Enum.max_by(list, &hd/1) end, 8 | "Cmp (list)" => fn -> Cmp.max_by(list, &hd/1) end, 9 | "Enum (dates)" => fn -> Enum.max_by(dates, &hd/1, Date) end, 10 | "Cmp (dates)" => fn -> Cmp.max_by(dates, &hd/1) end, 11 | "Enum (set)" => fn -> Enum.max_by(set, &hd/1) end, 12 | "Cmp (set)" => fn -> Cmp.max_by(set, &hd/1) end, 13 | }, 14 | time: 2 15 | ) 16 | -------------------------------------------------------------------------------- /benchmarks/max_by.results.txt: -------------------------------------------------------------------------------- 1 | Operating System: macOS 2 | CPU Information: Apple M1 3 | Number of Available Cores: 8 4 | Available memory: 16 GB 5 | Elixir 1.14.2 6 | Erlang 25.0 7 | 8 | Benchmark suite executing with the following configuration: 9 | warmup: 2 s 10 | time: 2 s 11 | memory time: 0 ns 12 | reduction time: 0 ns 13 | parallel: 1 14 | inputs: none specified 15 | Estimated total run time: 24 s 16 | 17 | Benchmarking Cmp (dates) ... 18 | Benchmarking Cmp (list) ... 19 | Benchmarking Cmp (set) ... 20 | Benchmarking Enum (dates) ... 21 | Benchmarking Enum (list) ... 22 | Benchmarking Enum (set) ... 23 | 24 | Name ips average deviation median 99th % 25 | Cmp (list) 176.44 K 5.67 μs ±37.02% 5.63 μs 5.88 μs 26 | Enum (list) 75.88 K 13.18 μs ±12.63% 13.08 μs 13.54 μs 27 | Enum (set) 35.21 K 28.40 μs ±9.87% 26.75 μs 32.58 μs 28 | Cmp (set) 31.43 K 31.82 μs ±9.43% 29.88 μs 36.29 μs 29 | Enum (dates) 28.80 K 34.72 μs ±4.25% 34.54 μs 36.54 μs 30 | Cmp (dates) 20.85 K 47.96 μs ±4.02% 47.75 μs 53.86 μs 31 | 32 | Comparison: 33 | Cmp (list) 176.44 K 34 | Enum (list) 75.88 K - 2.33x slower +7.51 μs 35 | Enum (set) 35.21 K - 5.01x slower +22.74 μs 36 | Cmp (set) 31.43 K - 5.61x slower +26.15 μs 37 | Enum (dates) 28.80 K - 6.13x slower +29.05 μs 38 | Cmp (dates) 20.85 K - 8.46x slower +42.30 μs 39 | -------------------------------------------------------------------------------- /benchmarks/sort.exs: -------------------------------------------------------------------------------- 1 | list = Enum.shuffle(1..100) 2 | dates = Enum.map(list, &Date.add(~D[2020-01-02], &1)) 3 | set = MapSet.new(list) 4 | 5 | Benchee.run( 6 | %{ 7 | "Enum (list)" => fn -> Enum.sort(list) end, 8 | "Cmp (list)" => fn -> Cmp.sort(list) end, 9 | "Enum (dates)" => fn -> Enum.sort(dates, Date) end, 10 | "Cmp (dates)" => fn -> Cmp.sort(dates) end, 11 | "Enum (list, desc)" => fn -> Enum.sort(list, :desc) end, 12 | "Cmp (list, desc)" => fn -> Cmp.sort(list, :desc) end, 13 | "Enum (set)" => fn -> Enum.sort(set) end, 14 | "Cmp (set)" => fn -> Cmp.sort(set) end, 15 | }, 16 | time: 2, 17 | memory_time: 0.5 18 | ) 19 | -------------------------------------------------------------------------------- /benchmarks/sort.results.txt: -------------------------------------------------------------------------------- 1 | Operating System: macOS 2 | CPU Information: Apple M1 3 | Number of Available Cores: 8 4 | Available memory: 16 GB 5 | Elixir 1.14.2 6 | Erlang 25.0 7 | 8 | Benchmark suite executing with the following configuration: 9 | warmup: 2 s 10 | time: 2 s 11 | memory time: 500 ms 12 | reduction time: 0 ns 13 | parallel: 1 14 | inputs: none specified 15 | Estimated total run time: 36 s 16 | 17 | Benchmarking Cmp (dates) ... 18 | Benchmarking Cmp (list) ... 19 | Benchmarking Cmp (list, desc) ... 20 | Benchmarking Cmp (set) ... 21 | Benchmarking Enum (dates) ... 22 | Benchmarking Enum (list) ... 23 | Benchmarking Enum (list, desc) ... 24 | Benchmarking Enum (set) ... 25 | 26 | Name ips average deviation median 99th % 27 | Enum (list) 810.95 K 1.23 μs ±814.33% 1.08 μs 3.13 μs 28 | Cmp (list) 718.55 K 1.39 μs ±744.30% 1.25 μs 2.92 μs 29 | Cmp (list, desc) 715.64 K 1.40 μs ±461.16% 1.25 μs 4.29 μs 30 | Enum (list, desc) 181.22 K 5.52 μs ±36.90% 5.33 μs 9.67 μs 31 | Cmp (set) 129.10 K 7.75 μs ±30.49% 7.38 μs 9.83 μs 32 | Enum (set) 126.47 K 7.91 μs ±23.60% 7.75 μs 9.17 μs 33 | Enum (dates) 42.72 K 23.41 μs ±6.75% 23.17 μs 24.67 μs 34 | Cmp (dates) 39.30 K 25.45 μs ±6.27% 25.21 μs 26.58 μs 35 | 36 | Comparison: 37 | Enum (list) 810.95 K 38 | Cmp (list) 718.55 K - 1.13x slower +0.159 μs 39 | Cmp (list, desc) 715.64 K - 1.13x slower +0.164 μs 40 | Enum (list, desc) 181.22 K - 4.48x slower +4.29 μs 41 | Cmp (set) 129.10 K - 6.28x slower +6.51 μs 42 | Enum (set) 126.47 K - 6.41x slower +6.67 μs 43 | Enum (dates) 42.72 K - 18.98x slower +22.18 μs 44 | Cmp (dates) 39.30 K - 20.64x slower +24.21 μs 45 | 46 | Memory usage statistics: 47 | 48 | Name Memory usage 49 | Enum (list) 7.91 KB 50 | Cmp (list) 7.91 KB - 1.00x memory usage +0 KB 51 | Cmp (list, desc) 9.47 KB - 1.20x memory usage +1.56 KB 52 | Enum (list, desc) 9.59 KB - 1.21x memory usage +1.69 KB 53 | Cmp (set) 19.84 KB - 2.51x memory usage +11.94 KB 54 | Enum (set) 19.80 KB - 2.50x memory usage +11.90 KB 55 | Enum (dates) 49.08 KB - 6.21x memory usage +41.17 KB 56 | Cmp (dates) 49.08 KB - 6.21x memory usage +41.17 KB 57 | 58 | **All measurements for memory usage were the same** 59 | -------------------------------------------------------------------------------- /benchmarks/sort_by.exs: -------------------------------------------------------------------------------- 1 | list = Enum.shuffle(1..1000) |> Enum.map(&[&1]) 2 | dates = Enum.map(list, fn [i] -> [Date.add(~D[2020-01-02], i)] end) 3 | set = MapSet.new(list) 4 | 5 | Benchee.run( 6 | %{ 7 | "Enum (list)" => fn -> Enum.sort_by(list, &hd/1) end, 8 | "Cmp (list)" => fn -> Cmp.sort_by(list, &hd/1) end, 9 | "Enum (dates)" => fn -> Enum.sort_by(dates, &hd/1, Date) end, 10 | "Cmp (dates)" => fn -> Cmp.sort_by(dates, &hd/1) end, 11 | "Enum (set)" => fn -> Enum.sort_by(set, &hd/1) end, 12 | "Cmp (set)" => fn -> Cmp.sort_by(set, &hd/1) end, 13 | }, 14 | time: 2, 15 | memory_time: 0.5 16 | ) 17 | -------------------------------------------------------------------------------- /benchmarks/sort_by.results.txt: -------------------------------------------------------------------------------- 1 | Operating System: macOS 2 | CPU Information: Apple M1 3 | Number of Available Cores: 8 4 | Available memory: 16 GB 5 | Elixir 1.14.2 6 | Erlang 25.0 7 | 8 | Benchmark suite executing with the following configuration: 9 | warmup: 2 s 10 | time: 2 s 11 | memory time: 500 ms 12 | reduction time: 0 ns 13 | parallel: 1 14 | inputs: none specified 15 | Estimated total run time: 27 s 16 | 17 | Benchmarking Cmp (dates) ... 18 | Benchmarking Cmp (list) ... 19 | Benchmarking Cmp (set) ... 20 | Benchmarking Enum (dates) ... 21 | Benchmarking Enum (list) ... 22 | Benchmarking Enum (set) ... 23 | 24 | Name ips average deviation median 99th % 25 | Cmp (list) 17.33 K 57.70 μs ±17.84% 55.92 μs 100.21 μs 26 | Enum (list) 14.76 K 67.73 μs ±39.34% 62.96 μs 124.79 μs 27 | Cmp (set) 13.07 K 76.49 μs ±17.39% 72.42 μs 126.95 μs 28 | Enum (set) 12.98 K 77.05 μs ±12.57% 77.42 μs 118.93 μs 29 | Enum (dates) 2.16 K 463.43 μs ±8.29% 455.29 μs 658.61 μs 30 | Cmp (dates) 1.75 K 572.11 μs ±7.89% 564.46 μs 764.68 μs 31 | 32 | Comparison: 33 | Cmp (list) 17.33 K 34 | Enum (list) 14.76 K - 1.17x slower +10.03 μs 35 | Cmp (set) 13.07 K - 1.33x slower +18.79 μs 36 | Enum (set) 12.98 K - 1.34x slower +19.35 μs 37 | Enum (dates) 2.16 K - 8.03x slower +405.73 μs 38 | Cmp (dates) 1.75 K - 9.92x slower +514.41 μs 39 | 40 | Memory usage statistics: 41 | 42 | Name Memory usage 43 | Cmp (list) 169.06 KB 44 | Enum (list) 169.01 KB - 1.00x memory usage -0.05469 KB 45 | Cmp (set) 221.84 KB - 1.31x memory usage +52.78 KB 46 | Enum (set) 196.64 KB - 1.16x memory usage +27.58 KB 47 | Enum (dates) 784.30 KB - 4.64x memory usage +615.23 KB 48 | Cmp (dates) 784.25 KB - 4.64x memory usage +615.19 KB 49 | 50 | **All measurements for memory usage were the same** 51 | -------------------------------------------------------------------------------- /lib/cmp.ex: -------------------------------------------------------------------------------- 1 | defmodule Cmp do 2 | @moduledoc """ 3 | Semantic comparison and sorting for Elixir. 4 | 5 | ## Why `Cmp`? 6 | 7 | The built-in comparison operators as well as functions like `Enum.sort/2` or `Enum.max/1` 8 | are based on Erlang's term ordering and suffer two issues, which require attention and 9 | might lead to unexpected behaviors or bugs: 10 | 11 | ### 1. Structural comparisons 12 | 13 | Built-ins use [structural comparison over semantic comparison]( 14 | https://hexdocs.pm/elixir/Kernel.html#module-structural-comparison): 15 | 16 | ~D[2020-03-02] > ~D[2019-06-06] 17 | false 18 | 19 | iex> Enum.sort([~D[2019-01-01], ~D[2020-03-02], ~D[2019-06-06]]) 20 | [~D[2019-01-01], ~D[2020-03-02], ~D[2019-06-06]] 21 | 22 | Semantic comparison is available but not straightforward: 23 | 24 | iex> Date.compare(~D[2019-01-01], ~D[2020-03-02]) 25 | :lt 26 | 27 | iex> Enum.sort([~D[2019-01-01], ~D[2020-03-02], ~D[2019-06-06]], Date) 28 | [~D[2019-01-01], ~D[2019-06-06], ~D[2020-03-02]] 29 | 30 | `Cmp` does the right thing out of the box: 31 | 32 | iex> Cmp.gt?(~D[2020-03-02], ~D[2019-06-06]) 33 | true 34 | 35 | iex> Cmp.sort([~D[2019-01-01], ~D[2020-03-02], ~D[2019-06-06]]) 36 | [~D[2019-01-01], ~D[2019-06-06], ~D[2020-03-02]] 37 | 38 | ### 2. Weakly typed 39 | 40 | Built-in comparators accept any set of operands: 41 | 42 | 2 < "1" 43 | true 44 | 45 | 0 < true 46 | true 47 | 48 | false < nil 49 | true 50 | 51 | `Cmp` will only compare compatible elements or raise a `Cmp.TypeError`: 52 | 53 | iex> Cmp.lte?(1, 1.0) 54 | true 55 | 56 | iex> Cmp.lte?(2, "1") 57 | ** (Cmp.TypeError) Failed to compare incompatible types - left: 2, right: "1" 58 | 59 | ## What's in the box 60 | 61 | - Boolean comparisons: `eq?/2`, `lt?/2`, `gt?/2`, `lte?/2`, `gte?/2` 62 | - Equivalents of `Kernel.min/2`/ `Kernel.max/2`: `Cmp.min/2`, `Cmp.max/2` 63 | - Equivalents of `Enum.min/1`/ `Enum.max/1`/`Enum.sort/2`: `Cmp.min/1`, `Cmp.max/1`, `Cmp.sort/2` 64 | - `compare/2` 65 | 66 | ## Supported types 67 | 68 | The `Cmp.Comparable` protocol is implemented for the following types: 69 | 70 | - `Integer` 71 | - `Float` 72 | - `Bitstring` 73 | - `Date` 74 | - `Time` 75 | - `DateTime` 76 | - `NaiveDateTime` 77 | - `Version` 78 | - `Tuple` (see below) 79 | - `Decimal` (if available) 80 | 81 | It isn't implemented for atoms by design, since atoms are not semantically 82 | an ordered type. 83 | 84 | It supports tuples of the same size and types: 85 | 86 | iex> Cmp.max({12, ~D[2019-06-06]}, {12, ~D[2020-03-02]}) 87 | {12, ~D[2020-03-02]} 88 | 89 | iex> Cmp.max({12, "Foo"}, {15, nil}) 90 | ** (Cmp.TypeError) Failed to compare incompatible types - left: "Foo", right: nil 91 | 92 | iex> Cmp.max({12, "Foo"}, {15}) 93 | ** (Cmp.TypeError) Failed to compare incompatible types - left: {12, "Foo"}, right: {15} 94 | 95 | `Decimal` support can prevent nasty bugs too: 96 | 97 | iex> max(Decimal.new(2), Decimal.from_float(1.0)) 98 | Decimal.new("1.0") 99 | iex> Cmp.max(Decimal.new(2), Decimal.from_float(1.0)) 100 | Decimal.new(2) 101 | 102 | See the `Cmp.Comparable` documentation to implement the protocol for other existing 103 | or new structs. 104 | 105 | ## Design goals 106 | 107 | - Fast and well-optimized - the overhead should be quite small over built-in equivalents. 108 | See the `benchmarks/` folder for more details. 109 | - No need to require macros, plain functions 110 | - Easily extensible through the `Cmp.Comparable` protocol 111 | - Robust and well-tested (both unit and property-based) 112 | 113 | Supporting comparisons between non-homogeneous types such as mixed `Decimal` and 114 | built-in numbers for instance is a non-goal. This limitation is a necessary 115 | trade-off in order to ensure the points above. Use the `Decimal` library 116 | directly if you need this. 117 | 118 | ## Limitations 119 | 120 | - `Cmp` comparators cannot be used in guards. 121 | - `Cmp` does not support (or plan to support) comparisons between non-homogeneous types 122 | (e.g. `Decimal` and native numbers). 123 | 124 | """ 125 | 126 | alias Cmp.Comparable 127 | alias Cmp.Util 128 | 129 | @compile :inline_list_funcs 130 | 131 | defguardp is_base_type(value) when is_number(value) or is_binary(value) 132 | 133 | defguardp is_same_base_type(left, right) 134 | when (is_number(left) and is_number(right)) or 135 | (is_binary(left) and is_binary(right)) 136 | 137 | @doc """ 138 | Safe equivalent to `>/2`, which only works if both types are compatible and 139 | uses semantic comparison. 140 | 141 | ## Examples 142 | 143 | iex> Cmp.gt?(2, 1) 144 | true 145 | 146 | iex> Cmp.gt?(1, 2) 147 | false 148 | 149 | iex> Cmp.gt?(1, 1.0) 150 | false 151 | 152 | It will raise a `Cmp.TypeError` if trying to compare incompatible types 153 | 154 | iex> Cmp.gt?(1, nil) 155 | ** (Cmp.TypeError) Failed to compare incompatible types - left: 1, right: nil 156 | 157 | Unlike `>/2`, it will perform a semantic comparison for structs and not a 158 | [structural comparison](https://hexdocs.pm/elixir/Kernel.html#module-structural-comparison): 159 | 160 | iex> Cmp.gt?(~D[2020-03-02], ~D[2019-06-06]) 161 | true 162 | 163 | ~D[2020-03-02] > ~D[2019-06-06] 164 | false 165 | 166 | """ 167 | @spec gt?(Comparable.t(), Comparable.t()) :: boolean() 168 | def gt?(left, right) when is_same_base_type(left, right), do: left > right 169 | def gt?(left, right), do: Comparable.compare(left, right) == :gt 170 | 171 | @doc """ 172 | Safe equivalent to `>=/2`, which only works if both types are compatible and 173 | uses semantic comparison. 174 | 175 | ## Examples 176 | 177 | iex> Cmp.gte?(2, 1) 178 | true 179 | 180 | iex> Cmp.gte?(1, 2) 181 | false 182 | 183 | iex> Cmp.gte?(1, 1.0) 184 | true 185 | 186 | It will raise a `Cmp.TypeError` if trying to compare incompatible types 187 | 188 | iex> Cmp.gte?(1, nil) 189 | ** (Cmp.TypeError) Failed to compare incompatible types - left: 1, right: nil 190 | 191 | Unlike `>=/2`, it will perform a semantic comparison for structs and not a 192 | [structural comparison](https://hexdocs.pm/elixir/Kernel.html#module-structural-comparison): 193 | 194 | iex> Cmp.gte?(~D[2020-03-02], ~D[2019-06-06]) 195 | true 196 | 197 | ~D[2020-03-02] >= ~D[2019-06-06] 198 | false 199 | 200 | """ 201 | @spec gte?(Comparable.t(), Comparable.t()) :: boolean() 202 | def gte?(left, right) when is_same_base_type(left, right), do: left >= right 203 | def gte?(left, right), do: Comparable.compare(left, right) != :lt 204 | 205 | @doc """ 206 | Safe equivalent to ` Cmp.lt?(1, 2) 212 | true 213 | 214 | iex> Cmp.lt?(2, 1) 215 | false 216 | 217 | iex> Cmp.lt?(1, 1.0) 218 | false 219 | 220 | It will raise a `Cmp.TypeError` if trying to compare incompatible types 221 | 222 | iex> Cmp.lt?(1, nil) 223 | ** (Cmp.TypeError) Failed to compare incompatible types - left: 1, right: nil 224 | 225 | Unlike ` Cmp.lt?(~D[2019-06-06], ~D[2020-03-02]) 229 | true 230 | 231 | ~D[2019-06-06] < ~D[2020-03-02] 232 | false 233 | 234 | """ 235 | @spec lt?(Comparable.t(), Comparable.t()) :: boolean() 236 | def lt?(left, right) when is_same_base_type(left, right), do: left < right 237 | def lt?(left, right), do: Comparable.compare(left, right) == :lt 238 | 239 | @doc """ 240 | Safe equivalent to `<=/2`, which only works if both types are compatible and 241 | uses semantic comparison. 242 | 243 | ## Examples 244 | 245 | iex> Cmp.lte?(1, 2) 246 | true 247 | 248 | iex> Cmp.lte?(2, 1) 249 | false 250 | 251 | iex> Cmp.lte?(1, 1.0) 252 | true 253 | 254 | It will raise a `Cmp.TypeError` if trying to compare incompatible types 255 | 256 | iex> Cmp.lte?(1, nil) 257 | ** (Cmp.TypeError) Failed to compare incompatible types - left: 1, right: nil 258 | 259 | Unlike `<=/2`, it will perform a semantic comparison for structs and not a 260 | [structural comparison](https://hexdocs.pm/elixir/Kernel.html#module-structural-comparison): 261 | 262 | iex> Cmp.lte?(~D[2019-06-06], ~D[2020-03-02]) 263 | true 264 | 265 | ~D[2019-06-06] <= ~D[2020-03-02] 266 | false 267 | 268 | """ 269 | @spec lte?(Comparable.t(), Comparable.t()) :: boolean() 270 | def lte?(left, right) when is_same_base_type(left, right), do: left <= right 271 | def lte?(left, right), do: Comparable.compare(left, right) != :gt 272 | 273 | @doc """ 274 | Safe equivalent to `==/2`, which only works if both types are compatible and 275 | uses semantic comparison. 276 | 277 | ## Examples 278 | 279 | iex> Cmp.eq?(2, 1) 280 | false 281 | 282 | iex> Cmp.eq?(1, 1.0) 283 | true 284 | 285 | It will raise a `Cmp.TypeError` if trying to compare incompatible types 286 | 287 | iex> Cmp.eq?(1, "") 288 | ** (Cmp.TypeError) Failed to compare incompatible types - left: 1, right: "" 289 | 290 | """ 291 | @spec eq?(Comparable.t(), Comparable.t()) :: boolean() 292 | def eq?(left, right) when is_same_base_type(left, right), do: left == right 293 | def eq?(left, right), do: Comparable.compare(left, right) == :eq 294 | 295 | @doc """ 296 | Returns `:gt` if `left` is semantically greater than `right`, `lt` if `left` 297 | is less than `right`, and `:eq` if they are equal. 298 | 299 | ## Examples 300 | 301 | iex> Cmp.compare(2, 1) 302 | :gt 303 | 304 | iex> Cmp.compare(1, 1.0) 305 | :eq 306 | 307 | It will raise a `Cmp.TypeError` if trying to compare incompatible types 308 | 309 | iex> Cmp.compare(1, "") 310 | ** (Cmp.TypeError) Failed to compare incompatible types - left: 1, right: "" 311 | 312 | """ 313 | @spec compare(Comparable.t(), Comparable.t()) :: :eq | :lt | :gt 314 | def compare(left, right) when is_same_base_type(left, right), 315 | do: Util.compare_terms(left, right) 316 | 317 | def compare(left, right), do: Comparable.compare(left, right) 318 | 319 | @doc """ 320 | Safe equivalent to `max/2`, which only works if both types are compatible and 321 | uses semantic comparison. 322 | 323 | ## Examples 324 | 325 | iex> Cmp.max(1, 2) 326 | 2 327 | 328 | iex> Cmp.max(1, 1.0) 329 | 1 330 | 331 | It will raise a `Cmp.TypeError` if trying to compare incompatible types 332 | 333 | iex> Cmp.max(1, nil) 334 | ** (Cmp.TypeError) Failed to compare incompatible types - left: 1, right: nil 335 | 336 | Unlike `max/2`, it will perform a semantic comparison for structs and not a 337 | [structural comparison](https://hexdocs.pm/elixir/Kernel.html#module-structural-comparison): 338 | 339 | iex> Cmp.max(~D[2020-03-02], ~D[2019-06-06]) 340 | ~D[2020-03-02] 341 | 342 | max(~D[2020-03-02], ~D[2019-06-06]) 343 | ~D[2019-06-06] 344 | 345 | """ 346 | @spec max(value, value) :: value when value: Comparable.t() 347 | def max(left, right) when is_same_base_type(left, right), do: Kernel.max(left, right) 348 | 349 | def max(left, right) do 350 | case Comparable.compare(left, right) do 351 | :lt -> right 352 | _ -> left 353 | end 354 | end 355 | 356 | @doc """ 357 | Safe equivalent to `min/2`, which only works if both types are compatible and 358 | uses semantic comparison. 359 | 360 | ## Examples 361 | 362 | iex> Cmp.min(2, 1) 363 | 1 364 | 365 | iex> Cmp.min(1, 1.0) 366 | 1 367 | 368 | It will raise a `Cmp.TypeError` if trying to compare incompatible types 369 | 370 | iex> Cmp.min(1, nil) 371 | ** (Cmp.TypeError) Failed to compare incompatible types - left: 1, right: nil 372 | 373 | Unlike `min/2`, it will perform a semantic comparison for structs and not a 374 | [structural comparison](https://hexdocs.pm/elixir/Kernel.html#module-structural-comparison): 375 | 376 | iex> Cmp.min(~D[2020-03-02], ~D[2019-06-06]) 377 | ~D[2019-06-06] 378 | 379 | min(~D[2020-03-02], ~D[2019-06-06]) 380 | ~D[2020-03-02] 381 | 382 | """ 383 | @spec min(value, value) :: value when value: Comparable.t() 384 | def min(left, right) when is_same_base_type(left, right), do: Kernel.min(left, right) 385 | 386 | def min(left, right) do 387 | case Comparable.compare(left, right) do 388 | :gt -> right 389 | _ -> left 390 | end 391 | end 392 | 393 | @doc """ 394 | Safe equivalent of `Enum.sort/2`. 395 | 396 | ## Examples 397 | 398 | iex> Cmp.sort([3, 1, 2]) 399 | [1, 2, 3] 400 | 401 | iex> Cmp.sort([3, 1, 2], :desc) 402 | [3, 2, 1] 403 | 404 | Respects semantic comparison: 405 | 406 | iex> Cmp.sort([~D[2019-01-01], ~D[2020-03-02], ~D[2019-06-06]]) 407 | [~D[2019-01-01], ~D[2019-06-06], ~D[2020-03-02]] 408 | 409 | Raises a `Cmp.TypeError` on non-uniform enumerables: 410 | 411 | iex> Cmp.sort([~D[2019-01-01], nil, ~D[2020-03-02]]) 412 | ** (Cmp.TypeError) Failed to compare incompatible types - left: ~D[2019-01-01], right: nil 413 | 414 | """ 415 | @spec sort(Enumerable.t(elem), :asc | :desc) :: Enumerable.t(elem) when elem: Comparable.t() 416 | def sort(enumerable, order \\ :asc) 417 | 418 | def sort([head | tail] = list, order) when is_base_type(head) do 419 | case order do 420 | :asc -> 421 | check_list(head, tail) 422 | :lists.sort(list) 423 | 424 | :desc -> 425 | [head | tail] = :lists.sort(list) 426 | check_reverse(head, tail, []) 427 | end 428 | end 429 | 430 | for struct <- Util.comparable_structs() do 431 | module = Module.concat(Cmp.Comparable, struct) 432 | 433 | def sort([%unquote(struct){} | _] = list, order) do 434 | gt_or_lt = 435 | case order do 436 | :asc -> :gt 437 | :desc -> :lt 438 | end 439 | 440 | :lists.sort(&(unquote(module).compare(&1, &2) != gt_or_lt), list) 441 | end 442 | end 443 | 444 | def sort([head | _] = list, order) do 445 | gt_or_lt = 446 | case order do 447 | :asc -> :gt 448 | :desc -> :lt 449 | end 450 | 451 | module = Comparable.impl_for!(head) 452 | :lists.sort(&(module.compare(&1, &2) != gt_or_lt), list) 453 | end 454 | 455 | def sort(list, :asc) when is_list(list) do 456 | :lists.sort(<e?/2, list) 457 | end 458 | 459 | def sort(list, :desc) when is_list(list) do 460 | :lists.sort(>e?/2, list) 461 | end 462 | 463 | def sort(enumerable, :asc) do 464 | Enum.sort(enumerable, <e?/2) 465 | end 466 | 467 | def sort(enumerable, :desc) do 468 | Enum.sort(enumerable, >e?/2) 469 | end 470 | 471 | defp check_list(_, []), do: :ok 472 | 473 | defp check_list(prev, [head | tail]) when is_same_base_type(prev, head) do 474 | check_list(head, tail) 475 | end 476 | 477 | defp check_list(prev, [head | _tail]) do 478 | raise Cmp.TypeError, left: prev, right: head 479 | end 480 | 481 | defp check_reverse(last, [], acc), do: [last | acc] 482 | 483 | defp check_reverse(prev, [head | tail], acc) when is_same_base_type(prev, head) do 484 | check_reverse(head, tail, [prev | acc]) 485 | end 486 | 487 | defp check_reverse(prev, [head | _tail], _acc) do 488 | raise Cmp.TypeError, left: head, right: prev 489 | end 490 | 491 | @doc """ 492 | Safe equivalent of `Enum.max/2`, returning the minimum of a non-empty 493 | enumerable of comparables. 494 | 495 | ## Examples 496 | 497 | iex> Cmp.max([1, 3, 2]) 498 | 3 499 | 500 | Respects semantic comparison: 501 | 502 | iex> Cmp.max([~D[2019-01-01], ~D[2020-03-02], ~D[2019-06-06]]) 503 | ~D[2020-03-02] 504 | 505 | Raises a `Cmp.TypeError` on non-uniform enumerables: 506 | 507 | iex> Cmp.max([1, nil, 2]) 508 | ** (Cmp.TypeError) Failed to compare incompatible types - left: 1, right: nil 509 | 510 | Raises an `Enum.EmptyError` on empty enumerables: 511 | 512 | iex> Cmp.max([]) 513 | ** (Enum.EmptyError) empty error 514 | 515 | """ 516 | @spec max(Enumerable.t(elem)) :: elem when elem: Comparable.t() 517 | def max(enumerable) 518 | 519 | def max([head | tail]) when is_number(head), do: max_list_numbers(tail, head) 520 | def max([head | tail]) when is_binary(head), do: max_list_binaries(tail, head) 521 | 522 | def max([head | _] = list) do 523 | module = Comparable.impl_for!(head) 524 | Enum.max(list, &(module.compare(&1, &2) != :lt)) 525 | end 526 | 527 | def max(enumerable) do 528 | Enum.max(enumerable, >e?/2) 529 | end 530 | 531 | @compile {:inline, max_list_numbers: 2, max_list_binaries: 2} 532 | 533 | for {fun, guard} <- [ 534 | max_list_numbers: :is_number, 535 | max_list_binaries: :is_binary 536 | ] do 537 | defp unquote(fun)([], acc), do: acc 538 | 539 | defp unquote(fun)([head | tail], acc) when unquote(guard)(head) do 540 | acc = 541 | case head do 542 | new_val when new_val > acc -> new_val 543 | _ -> acc 544 | end 545 | 546 | unquote(fun)(tail, acc) 547 | end 548 | 549 | defp unquote(fun)([head | _tail], acc) do 550 | raise Cmp.TypeError, left: acc, right: head 551 | end 552 | end 553 | 554 | @doc """ 555 | Safe equivalent of `Enum.min/2`, returning the maximum of a non-empty 556 | enumerable of comparables. 557 | 558 | ## Examples 559 | 560 | iex> Cmp.min([1, 3, 2]) 561 | 1 562 | 563 | Respects semantic comparison: 564 | 565 | iex> Cmp.min([~D[2020-03-02], ~D[2019-06-06]]) 566 | ~D[2019-06-06] 567 | 568 | Raises a `Cmp.TypeError` on non-uniform enumerables: 569 | 570 | iex> Cmp.min([1, nil, 2]) 571 | ** (Cmp.TypeError) Failed to compare incompatible types - left: 1, right: nil 572 | 573 | Raises an `Enum.EmptyError` on empty enumerables: 574 | 575 | iex> Cmp.min([]) 576 | ** (Enum.EmptyError) empty error 577 | 578 | """ 579 | @spec min(Enumerable.t(elem)) :: elem when elem: Comparable.t() 580 | def min(enumerable) 581 | 582 | def min([head | tail]) when is_number(head), do: min_list_numbers(tail, head) 583 | def min([head | tail]) when is_binary(head), do: min_list_binaries(tail, head) 584 | 585 | def min([head | _] = list) do 586 | module = Comparable.impl_for!(head) 587 | Enum.min(list, &(module.compare(&1, &2) != :gt)) 588 | end 589 | 590 | def min(enumerable) do 591 | Enum.min(enumerable, <e?/2) 592 | end 593 | 594 | @compile {:inline, min_list_numbers: 2, min_list_binaries: 2} 595 | 596 | for {fun, guard} <- [ 597 | min_list_numbers: :is_number, 598 | min_list_binaries: :is_binary 599 | ] do 600 | defp unquote(fun)([], acc), do: acc 601 | 602 | defp unquote(fun)([head | tail], acc) when unquote(guard)(head) do 603 | acc = 604 | case head do 605 | new_val when new_val < acc -> new_val 606 | _ -> acc 607 | end 608 | 609 | unquote(fun)(tail, acc) 610 | end 611 | 612 | defp unquote(fun)([head | _tail], acc) do 613 | raise Cmp.TypeError, left: acc, right: head 614 | end 615 | end 616 | 617 | @doc """ 618 | Safe equivalent of `Enum.sort_by/2`. 619 | 620 | ## Examples 621 | 622 | iex> Cmp.sort_by([%{x: 3}, %{x: 1}, %{x: 2}], & &1.x) 623 | [%{x: 1}, %{x: 2}, %{x: 3}] 624 | 625 | iex> Cmp.sort_by([%{x: 3}, %{x: 1}, %{x: 2}], & &1.x, :desc) 626 | [%{x: 3}, %{x: 2}, %{x: 1}] 627 | 628 | Respects semantic comparison: 629 | 630 | iex> Cmp.sort_by([%{date: ~D[2020-03-02]}, %{date: ~D[2019-06-06]}], & &1.date) 631 | [%{date: ~D[2019-06-06]}, %{date: ~D[2020-03-02]}] 632 | 633 | Raises a `Cmp.TypeError` on non-uniform enumerables: 634 | 635 | iex> Cmp.sort_by([%{x: 3}, %{x: "1"}, %{x: 2}], & &1.x) 636 | ** (Cmp.TypeError) Failed to compare incompatible types - left: 3, right: "1" 637 | 638 | """ 639 | @spec sort_by(Enumerable.t(elem), (elem -> Comparable.t()), :asc | :desc) :: Enumerable.t(elem) 640 | when elem: term() 641 | def sort_by(enumerable, fun, order \\ :asc) 642 | when is_function(fun, 1) and order in [:asc, :desc] do 643 | enumerable 644 | |> Enum.to_list() 645 | |> sort_by_prepare(fun) 646 | |> unwrap_sort_by(order) 647 | end 648 | 649 | defp sort_by_prepare([], _fun), do: [] 650 | 651 | defp sort_by_prepare([head | tail], fun) do 652 | case fun.(head) do 653 | val when is_number(val) -> 654 | sort_by_prepare_numbers(tail, fun, [{val, head}]) 655 | 656 | val when is_binary(val) -> 657 | sort_by_prepare_binaries(tail, fun, [{val, head}]) 658 | 659 | val -> 660 | module = Comparable.impl_for!(val) 661 | mapped = [{val, head} | Enum.map(tail, &{fun.(&1), &1})] 662 | :lists.sort(fn {left, _}, {right, _} -> module.compare(left, right) != :gt end, mapped) 663 | end 664 | end 665 | 666 | @compile {:inline, sort_by_prepare_numbers: 3} 667 | @compile {:inline, sort_by_prepare_binaries: 3} 668 | @compile {:inline, unwrap_sort_by_desc: 2} 669 | 670 | defp sort_by_prepare_numbers([], _fun, acc), do: :lists.keysort(1, acc) 671 | 672 | defp sort_by_prepare_numbers([head | tail], fun, acc) do 673 | case fun.(head) do 674 | val when is_number(val) -> 675 | sort_by_prepare_numbers(tail, fun, [{val, head} | acc]) 676 | 677 | other -> 678 | [{left, _} | _] = acc 679 | raise Cmp.TypeError, left: left, right: other 680 | end 681 | end 682 | 683 | defp sort_by_prepare_binaries([], _fun, acc), do: :lists.keysort(1, acc) 684 | 685 | defp sort_by_prepare_binaries([head | tail], fun, acc) do 686 | case fun.(head) do 687 | val when is_binary(val) -> 688 | sort_by_prepare_binaries(tail, fun, [{val, head} | acc]) 689 | 690 | other -> 691 | [{left, _} | _] = acc 692 | raise Cmp.TypeError, left: left, right: other 693 | end 694 | end 695 | 696 | defp unwrap_sort_by(list, :asc), do: unwrap_sort_by_asc(list) 697 | defp unwrap_sort_by(list, :desc), do: unwrap_sort_by_desc(list, []) 698 | 699 | defp unwrap_sort_by_asc([]), do: [] 700 | defp unwrap_sort_by_asc([{_val, elem} | tail]), do: [elem | unwrap_sort_by_asc(tail)] 701 | 702 | defp unwrap_sort_by_desc([], acc), do: acc 703 | 704 | defp unwrap_sort_by_desc([{_val, elem} | tail], acc), 705 | do: unwrap_sort_by_desc(tail, [elem | acc]) 706 | 707 | @doc """ 708 | Safe equivalent of `Enum.max_by/3`, returning the element of a non-empty 709 | enumerable for which `fun` gives the maximum comparable value. 710 | 711 | ## Examples 712 | 713 | iex> Cmp.max_by([%{x: 1}, %{x: 3}, %{x: 2}], & &1.x) 714 | %{x: 3} 715 | 716 | Respects semantic comparison: 717 | 718 | iex> Cmp.max_by([%{date: ~D[2020-03-02]}, %{date: ~D[2019-06-06]}], & &1.date) 719 | %{date: ~D[2020-03-02]} 720 | 721 | Raises a `Cmp.TypeError` on non-uniform enumerables: 722 | 723 | iex> Cmp.max_by([%{x: 1}, %{x: nil}, %{x: 2}], & &1.x) 724 | ** (Cmp.TypeError) Failed to compare incompatible types - left: 1, right: nil 725 | 726 | Raises an `Enum.EmptyError` on empty enumerables: 727 | 728 | iex> Cmp.max_by([], & &1.x) 729 | ** (Enum.EmptyError) empty error 730 | 731 | """ 732 | @spec max_by(Enumerable.t(elem), (elem -> Comparable.t())) :: elem when elem: term() 733 | def max_by(enumerable, fun) 734 | 735 | def max_by([head | tail], fun) when is_function(fun, 1) do 736 | case fun.(head) do 737 | val when is_number(val) -> 738 | max_by_list_numbers(tail, head, val, fun) 739 | 740 | val when is_binary(val) -> 741 | max_by_list_binaries(tail, head, val, fun) 742 | 743 | val -> 744 | module = Comparable.impl_for!(val) 745 | max_by_list(tail, head, val, fun, module) 746 | end 747 | end 748 | 749 | def max_by(enumerable, fun) when is_function(fun, 1) do 750 | Enum.max_by(enumerable, fun, __MODULE__) 751 | end 752 | 753 | @compile {:inline, max_by_list: 5, max_by_list_numbers: 4, max_by_list_binaries: 4} 754 | 755 | defp max_by_list([], elem, _val, _fun, _module), do: elem 756 | 757 | defp max_by_list([head | tail], elem, val, fun, module) do 758 | new_val = fun.(head) 759 | 760 | case module.compare(val, new_val) do 761 | :lt -> max_by_list(tail, head, new_val, fun, module) 762 | _ -> max_by_list(tail, elem, val, fun, module) 763 | end 764 | end 765 | 766 | for {fun, guard} <- [ 767 | max_by_list_numbers: :is_number, 768 | max_by_list_binaries: :is_binary 769 | ] do 770 | defp unquote(fun)([], elem, _val, _fun), do: elem 771 | 772 | defp unquote(fun)([head | tail], elem, val, fun) do 773 | case fun.(head) do 774 | other when not unquote(guard)(other) -> raise Cmp.TypeError, left: val, right: other 775 | new_val when new_val > val -> unquote(fun)(tail, head, new_val, fun) 776 | _ -> unquote(fun)(tail, elem, val, fun) 777 | end 778 | end 779 | end 780 | 781 | @doc """ 782 | Safe equivalent of `Enum.min_by/3`, returning the element of a non-empty 783 | enumerable for which `fun` gives the minimum comparable value. 784 | 785 | ## Examples 786 | 787 | iex> Cmp.min_by([%{x: 1}, %{x: 3}, %{x: 2}], & &1.x) 788 | %{x: 1} 789 | 790 | Respects semantic comparison: 791 | 792 | iex> Cmp.min_by([%{date: ~D[2020-03-02]}, %{date: ~D[2019-06-06]}], & &1.date) 793 | %{date: ~D[2019-06-06]} 794 | 795 | Raises a `Cmp.TypeError` on non-uniform enumerables: 796 | 797 | iex> Cmp.min_by([%{x: 1}, %{x: nil}, %{x: 2}], & &1.x) 798 | ** (Cmp.TypeError) Failed to compare incompatible types - left: 1, right: nil 799 | 800 | Raises an `Enum.EmptyError` on empty enumerables: 801 | 802 | iex> Cmp.min_by([], & &1.x) 803 | ** (Enum.EmptyError) empty error 804 | 805 | """ 806 | @spec min_by(Enumerable.t(elem), (elem -> Comparable.t())) :: elem when elem: term() 807 | def min_by(enumerable, fun) 808 | 809 | def min_by([head | tail], fun) when is_function(fun, 1) do 810 | case fun.(head) do 811 | val when is_number(val) -> 812 | min_by_list_numbers(tail, head, val, fun) 813 | 814 | val when is_binary(val) -> 815 | min_by_list_binaries(tail, head, val, fun) 816 | 817 | val -> 818 | module = Comparable.impl_for!(val) 819 | min_by_list(tail, head, val, fun, module) 820 | end 821 | end 822 | 823 | def min_by(enumerable, fun) when is_function(fun, 1) do 824 | Enum.min_by(enumerable, fun, __MODULE__) 825 | end 826 | 827 | @compile {:inline, min_by_list: 5, min_by_list_numbers: 4, min_by_list_binaries: 4} 828 | 829 | defp min_by_list([], elem, _val, _fun, _module), do: elem 830 | 831 | defp min_by_list([head | tail], elem, val, fun, module) do 832 | new_val = fun.(head) 833 | 834 | case module.compare(val, new_val) do 835 | :gt -> min_by_list(tail, head, new_val, fun, module) 836 | _ -> min_by_list(tail, elem, val, fun, module) 837 | end 838 | end 839 | 840 | for {fun, guard} <- [ 841 | min_by_list_numbers: :is_number, 842 | min_by_list_binaries: :is_binary 843 | ] do 844 | defp unquote(fun)([], elem, _val, _fun), do: elem 845 | 846 | defp unquote(fun)([head | tail], elem, val, fun) do 847 | case fun.(head) do 848 | other when not unquote(guard)(other) -> raise Cmp.TypeError, left: val, right: other 849 | new_val when new_val < val -> unquote(fun)(tail, head, new_val, fun) 850 | _ -> unquote(fun)(tail, elem, val, fun) 851 | end 852 | end 853 | end 854 | end 855 | -------------------------------------------------------------------------------- /lib/cmp/comparable.ex: -------------------------------------------------------------------------------- 1 | defprotocol Cmp.Comparable do 2 | @moduledoc """ 3 | A protocol to define how elements of a type can be compared. 4 | 5 | The simplest way to define it for a custom struct is to use @derive 6 | based on semantical comparison of fields, in the given order: 7 | 8 | defmodule MyStruct do 9 | @derive {Cmp.Comparable, using: [:date, :id]} 10 | 11 | defstruct [:id, :date] 12 | end 13 | 14 | Cmp.sort([ 15 | %MyStruct{date: ~D[2020-03-02], id: 100}, 16 | %MyStruct{date: ~D[2020-03-02], id: 101}, 17 | %MyStruct{date: ~D[2019-06-06], id: 300} 18 | ]) 19 | [ 20 | %MyStruct{date: ~D[2019-06-06], id: 300}, 21 | %MyStruct{date: ~D[2020-03-02], id: 100}, 22 | %MyStruct{date: ~D[2020-03-02], id: 101} 23 | ] 24 | 25 | For existing structs that are already implementing a `compare/2` 26 | function returning `:eq | :lt | :gt`, the protocol can simply be 27 | done by passing `using: :compare`. 28 | 29 | require Protocol 30 | Protocol.derive(Cmp.Comparable, MyStruct, using: :compare) 31 | 32 | This is already done for the following modules: 33 | - `Date` 34 | - `Time` 35 | - `DateTime` 36 | - `NaiveDateTime` 37 | - `Version` 38 | - `Decimal` (if available) 39 | 40 | """ 41 | 42 | # @type t :: term() 43 | @typedoc """ 44 | Type of an element implementing the `Comparable` protocol. 45 | """ 46 | 47 | @doc """ 48 | Defines how to semantically compare two elements of a given type. 49 | 50 | This should return: 51 | - `:eq` if `left == right` 52 | - `:lt` if `left < right` 53 | - `:gt` if `left > right` 54 | 55 | """ 56 | @spec compare(t(), t()) :: :eq | :lt | :gt 57 | def compare(left, right) 58 | end 59 | 60 | defimpl Cmp.Comparable, for: Integer do 61 | def compare(left, right) when is_number(right) do 62 | Cmp.Util.compare_terms(left, right) 63 | end 64 | 65 | def compare(left, right), do: raise(Cmp.TypeError, left: left, right: right) 66 | end 67 | 68 | defimpl Cmp.Comparable, for: Float do 69 | def compare(left, right) when is_number(right) do 70 | Cmp.Util.compare_terms(left, right) 71 | end 72 | 73 | def compare(left, right), do: raise(Cmp.TypeError, left: left, right: right) 74 | end 75 | 76 | defimpl Cmp.Comparable, for: BitString do 77 | def compare(left, right) when is_binary(right) do 78 | Cmp.Util.compare_terms(left, right) 79 | end 80 | 81 | def compare(left, right), do: raise(Cmp.TypeError, left: left, right: right) 82 | end 83 | 84 | defimpl Cmp.Comparable, for: Any do 85 | defmacro __deriving__(module, _struct, using: :compare) do 86 | quote do 87 | defimpl Cmp.Comparable, for: unquote(module) do 88 | @compile {:inline, compare: 2} 89 | 90 | def compare(left, right) when is_struct(right, unquote(module)) do 91 | case unquote(module).compare(left, right) do 92 | result when result in [:lt, :gt, :eq] -> result 93 | end 94 | end 95 | 96 | def compare(left, right), do: raise(Cmp.TypeError, left: left, right: right) 97 | end 98 | end 99 | end 100 | 101 | defmacro __deriving__(module, _struct, using: fields) when is_list(fields) do 102 | vars = Map.new([:left, :right], &{&1, Macro.var(&1, __MODULE__)}) 103 | 104 | quote do 105 | defimpl Cmp.Comparable, for: unquote(module) do 106 | def compare(unquote(vars.left), unquote(vars.right)) 107 | when is_struct(unquote(vars.right), unquote(module)) do 108 | unquote(generate_fields_comparison(fields, vars)) 109 | end 110 | 111 | def compare(left, right), do: raise(Cmp.TypeError, left: left, right: right) 112 | end 113 | end 114 | end 115 | 116 | defmacro __deriving__(module, _struct, _opts) do 117 | raise ArgumentError, """ 118 | Deriving Cmp.Comparable for #{inspect(module)} needs to pass a :using option which is either: 119 | 120 | - `using: :compare` if you plan to define a compare/2 function 121 | - `using: [:field1, :field2] if you want to use a list of fields 122 | 123 | """ 124 | end 125 | 126 | def compare(left, _right) do 127 | raise Protocol.UndefinedError, 128 | protocol: @protocol, 129 | value: left 130 | end 131 | 132 | defp generate_fields_comparison([field], vars) when is_atom(field) do 133 | quote do 134 | Cmp.compare( 135 | Map.fetch!(unquote(vars.left), unquote(field)), 136 | Map.fetch!(unquote(vars.right), unquote(field)) 137 | ) 138 | end 139 | end 140 | 141 | defp generate_fields_comparison([field | fields], vars) when is_atom(field) do 142 | comparison = generate_fields_comparison([field], vars) 143 | continue = generate_fields_comparison(fields, vars) 144 | 145 | quote do 146 | case unquote(comparison) do 147 | :eq -> unquote(continue) 148 | result when result in [:lt, :gt] -> result 149 | end 150 | end 151 | end 152 | end 153 | 154 | for struct <- Cmp.Util.comparable_structs() do 155 | require Protocol 156 | Protocol.derive(Cmp.Comparable, struct, using: :compare) 157 | end 158 | 159 | defimpl Cmp.Comparable, for: Tuple do 160 | def compare(left, right) when is_tuple(right) and tuple_size(left) == tuple_size(right) do 161 | compare_tuples(left, right, 1, tuple_size(left)) 162 | end 163 | 164 | def compare(left, right), do: raise(Cmp.TypeError, left: left, right: right) 165 | 166 | defp compare_tuples(_left, _right, i, n) when i > n, do: :eq 167 | 168 | defp compare_tuples(left, right, i, n) do 169 | l = :erlang.element(i, left) 170 | r = :erlang.element(i, right) 171 | 172 | case Cmp.compare(l, r) do 173 | :eq -> 174 | compare_tuples(left, right, i + 1, n) 175 | 176 | result -> 177 | # keep checking types even if no impact on result 178 | keep_checking(left, right, i + 1, n) 179 | result 180 | end 181 | end 182 | 183 | defp keep_checking(_left, _right, i, n) when i > n, do: :ok 184 | 185 | defp keep_checking(left, right, i, n) do 186 | l = :erlang.element(i, left) 187 | r = :erlang.element(i, right) 188 | Cmp.compare(l, r) 189 | keep_checking(left, right, i + 1, n) 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /lib/cmp/type_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Cmp.TypeError do 2 | @moduledoc """ 3 | Error raised when trying to compare values of different types. 4 | """ 5 | 6 | defexception [:left, :right] 7 | 8 | @impl true 9 | def exception(left: left, right: right) do 10 | %__MODULE__{left: left, right: right} 11 | end 12 | 13 | @impl true 14 | def message(%__MODULE__{left: left, right: right}) do 15 | "Failed to compare incompatible types - left: #{inspect(left)}, right: #{inspect(right)}" 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/cmp/util.ex: -------------------------------------------------------------------------------- 1 | defmodule Cmp.Util do 2 | def compare_terms(left, right) when left == right, do: :eq 3 | def compare_terms(left, right) when left < right, do: :lt 4 | def compare_terms(_left, _right), do: :gt 5 | 6 | def comparable_structs do 7 | base_structs = [Date, Time, DateTime, NaiveDateTime, Version] 8 | 9 | if Code.ensure_loaded?(Decimal) do 10 | [Decimal | base_structs] 11 | else 12 | base_structs 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Cmp.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.1.3" 5 | @github_url "https://github.com/sabiwara/cmp" 6 | 7 | def project do 8 | [ 9 | app: :cmp, 10 | version: @version, 11 | elixir: "~> 1.13", 12 | start_permanent: Mix.env() == :prod, 13 | deps: deps(), 14 | dialyzer: [flags: [:missing_return, :extra_return]], 15 | aliases: aliases(), 16 | consolidate_protocols: Mix.env() != :test, 17 | 18 | # hex 19 | description: "Semantic comparison and sorting for Elixir", 20 | package: package(), 21 | name: "Cmp", 22 | docs: docs() 23 | ] 24 | end 25 | 26 | def application do 27 | [] 28 | end 29 | 30 | defp deps do 31 | [ 32 | # Optional dependencies 33 | {:decimal, "~> 2.0", optional: true}, 34 | # doc, benchs 35 | {:ex_doc, "~> 0.28", only: :docs, runtime: false}, 36 | {:benchee, "~> 1.1", only: :bench, runtime: false}, 37 | # CI 38 | {:dialyxir, "~> 1.0", only: :test, runtime: false}, 39 | {:stream_data, "~> 1.0", only: :test} 40 | ] 41 | end 42 | 43 | defp package do 44 | [ 45 | maintainers: ["sabiwara"], 46 | licenses: ["MIT"], 47 | links: %{"GitHub" => @github_url}, 48 | files: ~w(lib mix.exs README.md LICENSE.md CHANGELOG.md) 49 | ] 50 | end 51 | 52 | defp aliases do 53 | [ 54 | "test.unit": ["test --exclude property:true"], 55 | "test.prop": ["test --only property:true"] 56 | ] 57 | end 58 | 59 | def cli do 60 | [ 61 | preferred_envs: [ 62 | docs: :docs, 63 | "hex.publish": :docs, 64 | dialyzer: :test, 65 | "test.unit": :test, 66 | "test.prop": :test 67 | ] 68 | ] 69 | end 70 | 71 | defp docs do 72 | [ 73 | main: "Cmp", 74 | source_ref: "v#{@version}", 75 | source_url: @github_url, 76 | homepage_url: @github_url, 77 | extras: ["README.md", "CHANGELOG.md", "LICENSE.md"] 78 | ] 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "benchee": {:hex, :benchee, "1.3.1", "c786e6a76321121a44229dde3988fc772bca73ea75170a73fd5f4ddf1af95ccf", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "76224c58ea1d0391c8309a8ecbfe27d71062878f59bd41a390266bf4ac1cc56d"}, 3 | "decimal": {:hex, :decimal, "2.2.0", "df3d06bb9517e302b1bd265c1e7f16cda51547ad9d99892049340841f3e15836", [:mix], [], "hexpm", "af8daf87384b51b7e611fb1a1f2c4d4876b65ef968fa8bd3adf44cff401c7f21"}, 4 | "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, 5 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 7 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 8 | "ex_doc": {:hex, :ex_doc, "0.35.1", "de804c590d3df2d9d5b8aec77d758b00c814b356119b3d4455e4b8a8687aecaf", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2121c6402c8d44b05622677b761371a759143b958c6c19f6558ff64d0aed40df"}, 9 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 10 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 11 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 12 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 13 | "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, 14 | "stream_data": {:hex, :stream_data, "1.1.2", "05499eaec0443349ff877aaabc6e194e82bda6799b9ce6aaa1aadac15a9fdb4d", [:mix], [], "hexpm", "129558d2c77cbc1eb2f4747acbbea79e181a5da51108457000020a906813a1a9"}, 15 | } 16 | -------------------------------------------------------------------------------- /test/cmp/comparable_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cmp.ComparableTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule NotImplemented do 5 | defstruct [:date, :id] 6 | end 7 | 8 | defmodule ManualCompare do 9 | @derive {Cmp.Comparable, using: :compare} 10 | 11 | defstruct [:date, :id] 12 | 13 | def compare(left, right) do 14 | case Cmp.compare(left.date, right.date) do 15 | :eq -> Cmp.compare(left.id, right.id) 16 | other -> other 17 | end 18 | end 19 | end 20 | 21 | describe "implementing Cmp.Comparable using: :compare" do 22 | setup %{} do 23 | structs = [ 24 | %ManualCompare{date: ~D[2019-06-06], id: 300}, 25 | %ManualCompare{date: ~D[2020-03-02], id: 100}, 26 | %ManualCompare{date: ~D[2020-03-02], id: 101} 27 | ] 28 | 29 | %{structs: structs} 30 | end 31 | 32 | test "sort/2", %{structs: structs} do 33 | shuffled = Enum.shuffle(structs) 34 | 35 | assert Cmp.sort(shuffled) == structs 36 | end 37 | 38 | test "max/1", %{structs: structs} do 39 | shuffled = Enum.shuffle(structs) 40 | 41 | assert Cmp.max(shuffled) == %ManualCompare{date: ~D[2020-03-02], id: 101} 42 | end 43 | 44 | test "min/1", %{structs: structs} do 45 | shuffled = Enum.shuffle(structs) 46 | 47 | assert Cmp.min(shuffled) == %ManualCompare{date: ~D[2019-06-06], id: 300} 48 | end 49 | end 50 | 51 | defmodule FieldsCompare do 52 | @derive {Cmp.Comparable, using: [:date, :id]} 53 | 54 | defstruct [:date, :id] 55 | end 56 | 57 | describe "implementing Cmp.Comparable using: fields" do 58 | setup %{} do 59 | structs = [ 60 | %FieldsCompare{date: ~D[2019-06-06], id: 300}, 61 | %FieldsCompare{date: ~D[2020-03-02], id: 100}, 62 | %FieldsCompare{date: ~D[2020-03-02], id: 101} 63 | ] 64 | 65 | %{structs: structs, shuffled: Enum.shuffle(structs)} 66 | end 67 | 68 | test "sort/2", %{structs: structs, shuffled: shuffled} do 69 | assert Cmp.sort(shuffled) == structs 70 | end 71 | 72 | test "max/1", %{shuffled: shuffled} do 73 | assert Cmp.max(shuffled) == %FieldsCompare{date: ~D[2020-03-02], id: 101} 74 | end 75 | 76 | test "min/1", %{shuffled: shuffled} do 77 | assert Cmp.min(shuffled) == %FieldsCompare{date: ~D[2019-06-06], id: 300} 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /test/cmp_prop_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cmp.PropTest do 2 | use ExUnit.Case, async: true 3 | use ExUnitProperties 4 | 5 | @moduletag timeout: :infinity 6 | @moduletag :property 7 | 8 | def log_rescale(generator) do 9 | scale(generator, &trunc(:math.log(&1))) 10 | end 11 | 12 | def number(), do: one_of([integer(), float()]) 13 | 14 | def date() do 15 | map({integer(0..3000), integer(1..365)}, fn {y, d} -> 16 | Date.new!(y, 1, 1) |> Date.add(d - 1) 17 | end) 18 | end 19 | 20 | @seconds 24 * 3600 21 | 22 | def time() do 23 | map(integer(1..@seconds), &Time.from_seconds_after_midnight(&1 - 1)) 24 | end 25 | 26 | defp types, do: [number(), binary(), date(), time(), {number(), binary()}, {date(), time()}] 27 | 28 | def pair() do 29 | types() 30 | |> Enum.map(&{&1, &1}) 31 | |> one_of() 32 | end 33 | 34 | def set_of(base, opts \\ []) do 35 | base |> list_of(opts) |> map(&MapSet.new/1) 36 | end 37 | 38 | def enumerable_of(base, opts) do 39 | one_of([list_of(base, opts), set_of(base, opts)]) 40 | end 41 | 42 | describe "consistency with Enum" do 43 | property "enumerable of numbers" do 44 | check all(values <- enumerable_of(number(), min_length: 1)) do 45 | assert Cmp.max(values) == Enum.max(values) 46 | assert Cmp.min(values) == Enum.min(values) 47 | assert Cmp.sort(values) == Enum.sort(values) 48 | assert Cmp.sort(values, :asc) == Enum.sort(values, :asc) 49 | assert Cmp.sort(values, :desc) == Enum.sort(values, :desc) 50 | 51 | fun = &abs/1 52 | assert Cmp.max_by(values, fun) == Enum.max_by(values, fun) 53 | assert Cmp.min_by(values, fun) == Enum.min_by(values, fun) 54 | 55 | # unfortunately, Enum.sort_by is inconsistent in how it is handling draws 56 | # order might differ for non-bijection funs, but still semantically correct 57 | fun = & &1 58 | assert Cmp.sort_by(values, fun) == Enum.sort_by(values, fun) 59 | assert Cmp.sort_by(values, fun, :desc) == Enum.sort_by(values, fun, :desc) 60 | end 61 | end 62 | 63 | property "list of binaries" do 64 | check all(values <- enumerable_of(binary(), min_length: 1)) do 65 | assert Cmp.max(values) == Enum.max(values) 66 | assert Cmp.min(values) == Enum.min(values) 67 | assert Cmp.sort(values) == Enum.sort(values) 68 | assert Cmp.sort(values, :asc) == Enum.sort(values, :asc) 69 | assert Cmp.sort(values, :desc) == Enum.sort(values, :desc) 70 | 71 | fun = &bit_size/1 72 | assert Cmp.max_by(values, fun) == Enum.max_by(values, fun) 73 | assert Cmp.min_by(values, fun) == Enum.min_by(values, fun) 74 | 75 | fun = & &1 76 | assert Cmp.sort_by(values, fun) == Enum.sort_by(values, fun) 77 | assert Cmp.sort_by(values, fun, :desc) == Enum.sort_by(values, fun, :desc) 78 | end 79 | end 80 | 81 | property "list of tuples of numbers" do 82 | check all(values <- enumerable_of({number(), number()}, min_length: 1)) do 83 | assert Cmp.max(values) == Enum.max(values) 84 | assert Cmp.min(values) == Enum.min(values) 85 | assert Cmp.sort(values) == Enum.sort(values) 86 | assert Cmp.sort(values, :asc) == Enum.sort(values, :asc) 87 | assert Cmp.sort(values, :desc) == Enum.sort(values, :desc) 88 | 89 | fun = fn {x, y} -> x + y end 90 | assert Cmp.max_by(values, fun) == Enum.max_by(values, fun) 91 | assert Cmp.min_by(values, fun) == Enum.min_by(values, fun) 92 | 93 | fun = & &1 94 | assert Cmp.sort_by(values, fun) == Enum.sort_by(values, fun) 95 | assert Cmp.sort_by(values, fun, :desc) == Enum.sort_by(values, fun, :desc) 96 | end 97 | end 98 | 99 | property "list of dates" do 100 | check all(values <- enumerable_of(date(), min_length: 1)) do 101 | assert Cmp.max(values) == Enum.max(values, Date) 102 | assert Cmp.min(values) == Enum.min(values, Date) 103 | assert Cmp.sort(values) == Enum.sort(values, Date) 104 | assert Cmp.sort(values, :asc) == Enum.sort(values, Date) 105 | assert Cmp.sort(values, :desc) == Enum.sort(values, {:desc, Date}) 106 | 107 | fun = &Date.beginning_of_week/1 108 | assert Cmp.max_by(values, fun) == Enum.max_by(values, fun, Date) 109 | assert Cmp.min_by(values, fun) == Enum.min_by(values, fun, Date) 110 | 111 | fun = & &1 112 | assert Cmp.sort_by(values, fun) == Enum.sort_by(values, fun, Date) 113 | assert Cmp.sort_by(values, fun, :desc) == Enum.sort_by(values, fun, {:desc, Date}) 114 | end 115 | end 116 | 117 | property "list of times" do 118 | check all(values <- enumerable_of(time(), min_length: 1)) do 119 | assert Cmp.max(values) == Enum.max(values, Time) 120 | assert Cmp.min(values) == Enum.min(values, Time) 121 | assert Cmp.sort(values) == Enum.sort(values, Time) 122 | assert Cmp.sort(values, :asc) == Enum.sort(values, Time) 123 | assert Cmp.sort(values, :desc) == Enum.sort(values, {:desc, Time}) 124 | 125 | fun = &Time.truncate(&1, :second) 126 | assert Cmp.max_by(values, fun) == Enum.max_by(values, fun, Time) 127 | assert Cmp.min_by(values, fun) == Enum.min_by(values, fun, Time) 128 | 129 | fun = & &1 130 | assert Cmp.sort_by(values, fun) == Enum.sort_by(values, fun, Time) 131 | assert Cmp.sort_by(values, fun, :desc) == Enum.sort_by(values, fun, {:desc, Time}) 132 | end 133 | end 134 | 135 | defmodule DateTimeTuple do 136 | def compare({d1, t1}, {d2, t2}) do 137 | dt1 = DateTime.new!(d1, t1) 138 | dt2 = DateTime.new!(d2, t2) 139 | DateTime.compare(dt1, dt2) 140 | end 141 | end 142 | 143 | property "list of tuples of dates and time" do 144 | check all(values <- enumerable_of({date(), time()}, min_length: 1)) do 145 | assert Cmp.max(values) == Enum.max(values, DateTimeTuple) 146 | assert Cmp.min(values) == Enum.min(values, DateTimeTuple) 147 | assert Cmp.sort(values) == Enum.sort(values, DateTimeTuple) 148 | assert Cmp.sort(values, :asc) == Enum.sort(values, DateTimeTuple) 149 | assert Cmp.sort(values, :desc) == Enum.sort(values, {:desc, DateTimeTuple}) 150 | end 151 | end 152 | end 153 | 154 | describe "binary comparisons" do 155 | property "boolean invariants" do 156 | check all({left, right} <- pair()) do 157 | assert Cmp.gt?(left, right) == not Cmp.lte?(left, right) 158 | assert Cmp.lt?(left, right) == not Cmp.gte?(left, right) 159 | assert (Cmp.lte?(left, right) and Cmp.gte?(left, right)) == Cmp.eq?(left, right) 160 | assert Cmp.lte?(left, right) == Cmp.lt?(left, right) or Cmp.eq?(left, right) 161 | assert Cmp.gte?(left, right) == Cmp.gt?(left, right) or Cmp.eq?(left, right) 162 | 163 | assert Cmp.eq?(left, right) == (left == right) 164 | end 165 | end 166 | 167 | property "min and max" do 168 | check all({left, right} <- pair()) do 169 | [min, max] = Cmp.sort([left, right]) 170 | assert Cmp.min(left, right) == min 171 | assert Cmp.max(left, right) == max 172 | assert Cmp.lte?(min, max) == true 173 | assert Cmp.gte?(max, min) == true 174 | end 175 | end 176 | 177 | property "compare consistency" do 178 | check all({left, right} <- pair()) do 179 | result = Cmp.compare(left, right) 180 | assert Cmp.eq?(left, right) == (result == :eq) 181 | assert Cmp.gt?(left, right) == (result == :gt) 182 | assert Cmp.lt?(left, right) == (result == :lt) 183 | 184 | assert Cmp.compare({left}, {right}) == result 185 | assert Cmp.compare({left, left}, {right, right}) == result 186 | assert Cmp.compare({left, 0}, {right, 0.0}) == result 187 | end 188 | end 189 | end 190 | 191 | describe "incompatible type guards" do 192 | property "list with a nil" do 193 | check all([head | tail] <- types() |> one_of() |> list_of(min_length: 1)) do 194 | values = [head] ++ Enum.shuffle([nil] ++ tail) 195 | 196 | assert_raise Cmp.TypeError, fn -> Cmp.max(values) end 197 | assert_raise Cmp.TypeError, fn -> Cmp.min(values) end 198 | assert_raise Cmp.TypeError, fn -> Cmp.sort(values) end 199 | assert_raise Cmp.TypeError, fn -> Cmp.sort(values, :asc) end 200 | assert_raise Cmp.TypeError, fn -> Cmp.sort(values, :desc) end 201 | 202 | fun = & &1 203 | assert_raise Cmp.TypeError, fn -> Cmp.max_by(values, fun) end 204 | assert_raise Cmp.TypeError, fn -> Cmp.min_by(values, fun) end 205 | assert_raise Cmp.TypeError, fn -> Cmp.sort_by(values, fun) end 206 | assert_raise Cmp.TypeError, fn -> Cmp.sort_by(values, fun, :asc) end 207 | assert_raise Cmp.TypeError, fn -> Cmp.sort_by(values, fun, :desc) end 208 | end 209 | end 210 | 211 | property "list with a different struct" do 212 | check all([head | tail] <- types() |> one_of() |> list_of(min_length: 1)) do 213 | values = [head] ++ Enum.shuffle([DateTime.utc_now()] ++ tail) 214 | 215 | assert_raise Cmp.TypeError, fn -> Cmp.max(values) end 216 | assert_raise Cmp.TypeError, fn -> Cmp.min(values) end 217 | assert_raise Cmp.TypeError, fn -> Cmp.sort(values) end 218 | assert_raise Cmp.TypeError, fn -> Cmp.sort(values, :asc) end 219 | assert_raise Cmp.TypeError, fn -> Cmp.sort(values, :desc) end 220 | 221 | fun = & &1 222 | assert_raise Cmp.TypeError, fn -> Cmp.max_by(values, fun) end 223 | assert_raise Cmp.TypeError, fn -> Cmp.min_by(values, fun) end 224 | assert_raise Cmp.TypeError, fn -> Cmp.sort_by(values, fun) end 225 | assert_raise Cmp.TypeError, fn -> Cmp.sort_by(values, fun, :asc) end 226 | assert_raise Cmp.TypeError, fn -> Cmp.sort_by(values, fun, :desc) end 227 | end 228 | end 229 | 230 | property "binary comparisons with nil (right)" do 231 | check all(elem <- types() |> one_of()) do 232 | assert_raise Cmp.TypeError, fn -> Cmp.compare(elem, nil) end 233 | assert_raise Cmp.TypeError, fn -> Cmp.gt?(elem, nil) end 234 | assert_raise Cmp.TypeError, fn -> Cmp.gte?(elem, nil) end 235 | assert_raise Cmp.TypeError, fn -> Cmp.lt?(elem, nil) end 236 | assert_raise Cmp.TypeError, fn -> Cmp.lte?(elem, nil) end 237 | assert_raise Cmp.TypeError, fn -> Cmp.eq?(elem, nil) end 238 | assert_raise Cmp.TypeError, fn -> Cmp.max(elem, nil) end 239 | assert_raise Cmp.TypeError, fn -> Cmp.min(elem, nil) end 240 | end 241 | end 242 | 243 | property "binary comparisons with nil (left)" do 244 | check all(elem <- types() |> one_of()) do 245 | assert_raise Protocol.UndefinedError, fn -> Cmp.compare(nil, elem) end 246 | assert_raise Protocol.UndefinedError, fn -> Cmp.gt?(nil, elem) end 247 | assert_raise Protocol.UndefinedError, fn -> Cmp.gte?(nil, elem) end 248 | assert_raise Protocol.UndefinedError, fn -> Cmp.lt?(nil, elem) end 249 | assert_raise Protocol.UndefinedError, fn -> Cmp.lte?(nil, elem) end 250 | assert_raise Protocol.UndefinedError, fn -> Cmp.eq?(nil, elem) end 251 | assert_raise Protocol.UndefinedError, fn -> Cmp.max(nil, elem) end 252 | assert_raise Protocol.UndefinedError, fn -> Cmp.min(nil, elem) end 253 | end 254 | end 255 | end 256 | end 257 | -------------------------------------------------------------------------------- /test/cmp_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CmpTest do 2 | use ExUnit.Case, async: true 3 | doctest Cmp 4 | 5 | describe "edge cases" do 6 | test "empty on empty enums" do 7 | assert_raise Enum.EmptyError, fn -> Cmp.max([]) end 8 | assert_raise Enum.EmptyError, fn -> Cmp.min([]) end 9 | end 10 | 11 | test "undefined protocol on single lists" do 12 | assert_raise Protocol.UndefinedError, fn -> Cmp.max([nil]) end 13 | assert_raise Protocol.UndefinedError, fn -> Cmp.min([nil]) end 14 | assert_raise Protocol.UndefinedError, fn -> Cmp.sort([nil]) end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------