billylanchantin

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, and not

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! :slight_smile:

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

Showing Posts 13 to 4

billylanchantin

billylanchantin OP

CompareChain Release: v0.6.0 (2025-01-09)

This release is largely to fix a warning: Elixir 1.17+ warned about the code a < b in one of the doctests.

It also shifts version support: it’s now Elixir 1.15 to 1.18 officially. Older versions of Elixir should still work, but they’re no longer “officially” supported.

Changelog

v0.6.0 (2025-01-09)

  • Drop official support for Elixir 1.13 and 1.14 (though they should still work)
  • Currently support Elixir 1.15 to 1.18
  • Change docs so they no longer warn about a doctest
  • Miscellanea: renamed LISENCE file, added CODEOWNERS file, etc.

As always, issues and PRs welcome :slight_smile:

billylanchantin

billylanchantin OP

CompareChain Release: v0.5.0 (2024-05-22)

Hi! Another release. There’s a bugfix and a few changes including one breaking change:

  • Allow === and !=== in expressions
  • Improve documentation
  • Fix bug with certain operation chains:
    compare?(1 < 2 != 3 < 4) #=> true
    compare?(1 < 2 != 3 > 4) #=> true (wrong!)
    
  • Improve error message and documentation for invalid expressions
  • [BREAKING] all branches of and, or and not must now contain comparisons
    • Example: compare?(1 < 2 and true) used to be ok but is no longer allowed because the right argument to and doesn’t contain a comparison.

As always, issues and PRs are welcome! :slight_smile:


A new section I added to the docs that I want to callout:

One last selling point

As a happy accident, CompareChain.compare?/2 always uses fewer characters than its compare/2 counterpart:

compare?(a <= b, Date)
# vs.
Date.compare(a, b) != :gt

(Assuming you’ve already included import CompareChain, of course!)

Because it’s shorter and more readable, these days I always use CompareChain for any semantic comparison, chained or not.

billylanchantin

billylanchantin OP

CompareChain Release: v0.4.0 (2023-09-10)

Hi, there’s been another (tiny) release of CompareChain.

This is the only change:

  • Using a struct with compare?/1 results in a warning

    Running:

    iex> compare?(~D[2023-01-02] < ~D[2023-01-02]) #=> false
    

    Now yields:

    [warning] Performing structural comparison on matching structs.
    
    Did you mean to use `compare?/2`?
    
      compare?(~D[2022-01-02] ??? ~D[2022-02-01], Date)
    

We decided that’s preferable to silently giving you the wrong answer and making you chase down a weird bug. Not speaking from experience or anything…

As always, issues and PRs are welcome! :slight_smile:

billylanchantin

billylanchantin OP

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

    compare?(~T[00:00:00] == ~T[11:11:11], Time) #=> false
    compare?(~T[00:00:00] != ~T[11:11:11], Time) #=> true
    

    (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! :slight_smile:

sbuttgereit

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/2 except 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/2 function that more or less mirrored the PostgreSQL comparison functions. So, whereas DateTime.compare/2 can return :eq, :lt, or :gt, my DbTypes.compare/2 can 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

billylanchantin OP

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).

Exactly!

Speaking of “fun”, that DateTimeWithInfinity example 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.)

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.

I’m curious what you mean by that. Like, custom handling of %Postgrex.Range{}?

tcoopman

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.

sbuttgereit

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.

billylanchantin

billylanchantin OP

We do a ton of datetime range comparisons at CargoSense

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 dt to some range {left, right}. What do you do?

The obvious answer is to check if dt is between left and right. But which “between” do you mean? There are actually 4 cases to cover:

  • left <= dt <= right
  • left <= dt < right
  • left < dt <= right
  • left < dt < right

We’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 like between_inclusive?.

benwilson512

benwilson512

Author of Craft GraphQL APIs in Elixir with Absinthe

not basically lets you pivot between logically equivalent renderings of the same thing. DeMorgan’s law says that:

a > b == not(a <= b)

and this extends to compound propositions like:

(a > b or a <= c) == not (a <= b and a > c)

Depending on what your function is doing, it might be easier to think of the logic in terms of or and having a choice between two things, or it might be easier to think of it in terms of and where several things all have to be true.

By supporting not you can turn an and into an or by pulling out a not to the front and vice versa.

Where Next? Top

Trending in Announcing Top

type1fool
WebAuthnLiveComponent WebAuthnComponents See this post about renaming the package. Passwordless authentication for Phoenix LiveView app...
New
GenericJam
Edit: 2026 May 15 - This post is archived. Mob is alive!! Main docs: mob v0.7.11 — Documentation A bit of explanation for the slightly c...
New
woylie
I released Doggo, a collection of unstyled Phoenix components. https://github.com/woylie/doggo Features Unstyled Phoenix components....
New
ahamez
Hi everyone, I’ve been working on this protobuf library for 3 years. We use it in the company I work for, EasyMile, to communicate with ...
New
garrison
Hobbes is a low-level distributed database for the Elixir programming language. Hobbes provides a simple, safe, and scalable storage lay...
New
kip
I’ll shortly be launching Text, a nascent text analysis library. Current functionality In this early version (not ready for prime time) ...
New
kip
Following on from my CLDR lbraries I started work on Unicode transforms. But like everything related to CLDR there is a lot of yak-shavin...
New

Other Trending Topics Top

mudasobwa
I am happy to introduce the very α version of the new programming language compiled to BEAM. Welcome Cure. It has literally three kille...
New
budgie
A little off-topic, but I feel like people here have a good head on their shoulders. I used to be quite good at making software. Was luc...
New
KristerV
Hey. Is there anyone here who creates agents in their apps? Not talking about using agents, but creating them. I’m finding it pretty diff...
New
webofbits
With AI doing more of the implementation work, I’ve been wondering how much coding I should deliberately keep doing myself. My main conc...
#ai
New
budgie
I love Elixir. It’s one of 2 programming languages I’ve ever fallen in love with. But I don’t use it anymore. Serverless was the promis...
New
type1fool
I just stumbled on a newly redesigned elixir-lang.org. :tada: It looks like @Software_Mansion did the work, and I think it is generally a...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews