billylanchantin
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
Trending in Announcing
Other Trending Topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #blog-post
- #phoenix_html
- #iex
- #graphql
- #ai
- #genstage
- #elixirconf-us
- #websockets
- #supervisor
- #advent-of-code
- #distillery
- #processes
- #api
- #forms
- #metaprogramming
- #security
- #hex











First 10 of 13 Posts
benwilson512
Woot, glad to see this got released! We do a ton of datetime range comparisons at CargoSense since we’re frequently having to compare whether the span of new data we’ve received is relevant to other spans of data, and to date it’s involved quite a lot of boilerplate helper functions. “Fence post” problems abound, since we want to try to make sure that every data point is always associated with a single window, not two if it’s on a boundary.
Writing helpers for this stuff has involved a ton of little boilerplate functions. Looking forward to the refactor PR now that this is out!
tcoopman
Do you have the
and notas an example here or does it add any value?iex> compare?(yesterday < today < tomorrow and not (today >= tomorrow),Today can’t be both strictly smaller and greater or equal, can’t it?
billylanchantin
Correct. That’s just an example of a legal expression.
Only in showing that
compare?/2isn’t returning nonsensebenwilson512
notbasically lets you pivot between logically equivalent renderings of the same thing. DeMorgan’s law says that:and this extends to compound propositions like:
Depending on what your function is doing, it might be easier to think of the logic in terms of
orand having a choice between two things, or it might be easier to think of it in terms ofandwhere several things all have to be true.By supporting
notyou can turn anandinto anorby pulling out anotto the front and vice versa.billylanchantin
You got that right!
Some additional context for this release: Like Ben said, at CargoSense we do stuff like this all. the. time. I have become an unwilling expert in the art of comparing datetime ranges.
Even for simple things, datetime comparisons can be cumbersome. For example, suppose you want to compare a datetime
dtto some range{left, right}. What do you do?The obvious answer is to check if
dtis betweenleftandright. But which “between” do you mean? There are actually 4 cases to cover:left <= dt <= rightleft <= dt < rightleft < dt <= rightleft < dt < rightWe’ve had occasion to need all 4 in one circumstance or another. And given the difficulties in reading the native datetime comparisons, we found ourselves writing a bunch of
defps all over the place. So we’re unreasonably excited by the prospect of in-lining a bunch of functions with names likebetween_inclusive?.sbuttgereit
It gets even more fun when you compare ranges to ranges since you may have overlapping ranges (e.g. Less Than, Not Overlapping vs. Less Than, Overlapping). For my project these range types originate in the database so I put similar functionality to what you’ve done here in the library I use close to where the database custom types are defined.
Nice to see a this kind of problem being looked at in a public library.
tcoopman
I know about the DeMorgan’s law. I just didn’t understand why the example shows duplicated logic and was wondering if it was a mistake.
billylanchantin
Exactly!
Speaking of “fun”, that
DateTimeWithInfinityexample is (almost) real. We often deal with ranges of time which have “started but not yet stopped”. E.g. a plane that took off at 11am but hasn’t landed yet. Even though the plane’s flight time doesn’t have a definite end, it should still overlap with any other range that starts after 11am.The moral of the story is that you end up doing a bunch of bespoke logic all over the place because it never quite seems to generalize nicely. (Or at least, we couldn’t get it to.)
I’m curious what you mean by that. Like, custom handling of
%Postgrex.Range{}?sbuttgereit
For the Range types, pretty much this. I’ve basically wrapped
%Postgrex.Range{}into type specific versions (e.g.DateRange,DecimalRange, etc.) which can be used with Ecto. Then I’ve defined a Range protocol and a more general database type protocol that defines functions for comparison handing; I need two protocols since some comparisons aren’t range related and the range related types have things like the overlapping conditions to consider.The Range protocol functions deal with things like upper bounds compare and lower bounds compare. The more general comparison function is modelled on the existing Elixir modules such as
DateTime.compare/2except that there is an expanded set of return values that it can return due to the complexity of range handling.In deciding what the range type comparisons would return, the range types in the application are largely just a reflection of the PostgreSQL range type implementations. When looking at the various operators that PostgreSQL implements to compare ranges, I figured I could create an extended set of return values from my
DbTypes.compare/2function that more or less mirrored the PostgreSQL comparison functions. So, whereasDateTime.compare/2can return:eq,:lt, or:gt, myDbTypes.compare/2can return (from my docs)::gt- left is greater than right.:lt- left is less than right.:eq- the values are equal.:lcr- left contains right.:rcl- right contains left.:gto- greater than overlapping.:lto- less than overlapping.Naturally, what of those can be returned in practice depends on what is being compared. So, for example, comparing a simple DateTime value to a DateTimeRange value cannot result in the overlapping return values though the “contains” values are possible. Anyway, I figure this keeps me in sync with what I can expect to do with the database directly using the PostgreSQL operators and keeps me reasonably aligned with how more complex comparisons are implemented elsewhere in the Elixir ecosystem.
billylanchantin
CompareChain Release: v0.3.0 (2023-01-28)
Hi (upwards of) tens of users! I’ve just released a new version of CompareChain.
There are two main changes:
You can now use
==and!=as well(No idea why I didn’t do this in the first place..)
You can now use Elixir >= 1.13.0 instead of being restricted to 1.14
I could probably go much lower if I stopped using
Macro.prewalker/1.As always, issues and PRs are welcome!