CompareChain
Announcing CompareChain - a small library to aid with comparisons.
Examples
iex> import CompareChain
# Chained comparisons
iex> compare?(1 < 2 < 3)
true
# Semantic comparisons
iex> compare?(~D[2017-03-31] < ~D[2017-04-01], Date)
true
# Semantic comparisons + logical operators
iex> compare?(~T[16:00:00] <= ~T[16:00:00] and not (~T[17:00:00] <= ~T[17:00:00]), Time)
false
# More complex expressions
iex> compare?(%{a: ~T[16:00:00]}.a <= ~T[17:00:00], Time)
true
Sales pitch
Working with comparison operators in Elixir can lead to a fair bit of boilerplate. This is because the normal infix comparison operators like <
do structural comparison:
iex> ~D[2017-03-31] < ~D[2017-04-01]
false
When you try that, you get a warning: warning: invalid comparison with struct literal ~D[2017-03-31]. Comparison operators (>, <, >=, <=, min, and max) perform structural and not semantic comparison...
To do semantic comparison, you need to use the proper module’s compare/2
function:
iex> Date.compare(~D[2017-03-31], ~D[2017-04-01]) == :lt
true
This ends up reading like RPN where :lt
acts somewhat like a postfix operator. The issue is compounded when you need to perform more complicated logic:
iex> Date.compare(~D[2017-03-31], ~D[2017-04-01]) == :lt and Date.compare(~D[2017-04-01], ~D[2017-04-02]) == :lt
true
You end up with a verbose mix of infix and pseudo-postfix operators.
Additionally, Elixir does not support chained comparisons like 1 < 2 < 3
:
iex> 1 < 2 < 3
false
When you try that, you get a warning: Elixir does not support nested comparisons...
Enter CompareChain
CompareChain
provides some helper macros that allow you to
- chain infix operators
- perform semantic comparison with infix operators
- combine (chained) comarisons with
and
,or
, andnot
After calling import CompareChain
, you get macros compare?/{1,2}
. With compare?/1
can do operations like:
iex> compare?(1 < 2 < 3)
true
iex> compare?(1 < 2 > 3)
false
With compare?/2
can do comparisons like:
iex> compare?(~D[2017-03-31] < ~D[2017-04-01], DateTime)
true
The idea is that you provide a module with a suitable compare/2
function as the second argument just like with functions like Enum.sort/2
. The macro then rewrites your expression using the module you provide.
You can write complicated expressions if you wish:
iex> yesterday = ~D[2022-11-04]
iex> today = ~D[2022-11-05]
iex> tomorrow = ~D[2022-11-06]
iex> compare?(yesterday < today < tomorrow and not (today >= tomorrow), Date)
true
iex> compare?(%{a: ~T[16:00:00]}.a <= ~T[17:00:00], Time)
true
You can also do fancier things by defining a custom module:
defmodule DateTimeWithInfinity do
def compare(:infinity, _), do: :gt
def compare(_, :infinity), do: :lt
def compare(:neg_infinity, _), do: :lt
def compare(_, :neg_infinity), do: :gt
def compare(%DateTime{} = dt1, %DateTime{} = dt2) do
DateTime.compare(dt1, dt2)
end
end
This module supports :infinity
as a value that is always greater than every date time, and :neg_infinity
that is always less than every datetime. This is super useful for defining ranges that are open on one side:
range1 = %{starts_at: ~U[2022-01-01T00:00:00Z], ends_at: ~U[2022-02-01T00:00:00Z]}
range2 = %{starts_at: ~U[2022-01-10T00:00:00Z], ends_at: :infinity}
compare?(
range2.starts_at <= range1.starts_at <= range2.ends_at or
range2.starts_at <= range1.ends_at <= range2.ends_at,
DateTimeWithInfinity)
#=> true
Future work
If you try it out and like it and/or find any problems, let me know! Issues and PRs are welcome.
Acknowledgements
Shoutout to @benwilson512 and @mcrumm for the helpful discussions and guidance!
And thank you to all the folks who participated in the elixir-lang-core discussion. In particular, thanks to Cliff (sorry I don’t know your handle) whose idea I shamelessly built off of: https://groups.google.com/g/elixir-lang-core/c/W2TeQm5r1H4/m/ctVuN_woBgAJ