Skip to content

Commit

Permalink
Prep for 0.5 release (#5)
Browse files Browse the repository at this point in the history
* try to use a more consistent comment style

* try to support more complex exprs

these are weird uses, but i'm not sure i want to require < to be at the root of the ast just yet

* improve documentation

* bump ex_doc

* bump project version

* changelog entry

* use fully qualified name for linking

* rewrite the whole thing to cover an edge case

* work with `===` and `!==`

* rename file

* weird typo

* test strict operators

* refactor tests a bit more

* two versions of the mixed operator test

* error message should include strict operators

* revert to 0.5 -- too many changes

* update version in README

* fix typo

* update warning text

* move more code to runtime

* readme tweaks

* ...changes?

* various error message and docs improvements

* change date to today (optimistic...)

* give actions a try!

* oh i don't think i needed to name the branch
  • Loading branch information
billylanchantin authored May 22, 2024
1 parent c34f254 commit 4bc64df
Show file tree
Hide file tree
Showing 11 changed files with 696 additions and 392 deletions.
85 changes: 85 additions & 0 deletions .github/workflows/elixir.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Adapted from: https://fly.io/phoenix-files/github-actions-for-elixir-ci/
name: Elixir CI

# Defines when the workflow runs
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]

# Sets the ENV `MIX_ENV` to `test` for running tests
env:
MIX_ENV: test

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
name: Test on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}}
strategy:
# Specify the OTP and Elixir versions to use when building
# and running the workflow steps.
matrix:
otp: ["25.0.4"]
elixir: ["1.14.1"]
steps:
# Step: Setup Elixir + Erlang image as the base.
- name: Set up Elixir
uses: erlef/setup-beam@v1
with:
otp-version: ${{matrix.otp}}
elixir-version: ${{matrix.elixir}}

# Step: Check out the code.
- name: Checkout code
uses: actions/checkout@v3

# Step: Define how to cache deps. Restores existing cache if present.
- name: Cache deps
id: cache-deps
uses: actions/cache@v3
env:
cache-name: cache-elixir-deps
with:
path: deps
key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }}
restore-keys: |
${{ runner.os }}-mix-${{ env.cache-name }}-
# Step: Define how to cache the `_build` directory. After the first run,
# this speeds up tests runs a lot. This includes not re-compiling our
# project's downloaded deps every run.
- name: Cache compiled build
id: cache-build
uses: actions/cache@v3
env:
cache-name: cache-compiled-build
with:
path: _build
key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }}
restore-keys: |
${{ runner.os }}-mix-${{ env.cache-name }}-
${{ runner.os }}-mix-
# Step: Download project dependencies. If unchanged, uses
# the cached version.
- name: Install dependencies
run: mix deps.get

# Step: Compile the project treating any warnings as errors.
# Customize this step if a different behavior is desired.
- name: Compiles without warnings
run: mix compile --warnings-as-errors

# Step: Check that the checked in code has already been formatted.
# This step fails if something was found unformatted.
# Customize this step as desired.
- name: Check Formatting
run: mix format --check-formatted

# Step: Execute the tests.
- name: Run tests
run: mix test
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# CHANGELOG

## v0.5.0 (2024-05-22)

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

## v0.4.0 (2023-09-10)

* Warn when `compare?/1` is used on a struct
Expand Down
176 changes: 135 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
Provides convenience macros for comparisons which do:

* chained comparisons like `a < b < c`
* semantic comparisons using the structural operators `<`, `>`, `<=`, `>=`, `==`, and `!=`
* semantic comparisons using the structural operators: `<`, `>`, `<=`, `>=`, `==`, `!=`, `===`, and `!==`
* combinations using `and`, `or`, and `not`

### Examples
Expand All @@ -21,6 +21,10 @@ true
iex> compare?(~D[2017-03-31] < ~D[2017-04-01], Date)
true

# Chained, semantic comparisons
iex> compare?(~D[2017-03-31] < ~D[2017-04-01] < ~D[2017-04-02], Date)
true

# Semantic comparisons with logical operators
iex> compare?(~T[16:00:00] <= ~T[16:00:00] and not (~T[17:00:00] <= ~T[17:00:00]), Time)
false
Expand All @@ -37,7 +41,7 @@ Add `compare_chain` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:compare_chain, "~> 0.3"}
{:compare_chain, "~> 0.5"}
]
end
```
Expand All @@ -46,68 +50,158 @@ Documentation can be found at <https://hexdocs.pm/compare_chain>.

## Usage

Once installed, you can add
Once installed, you can add:

```elixir
import CompareChain
```

to your `defmodule` and you will have access to `compare?/1` and `compare?/2`.
to your `defmodule` and you will have access to `CompareChain.compare?/1` and `CompareChain.compare?/2`.

## Background and motivation

Many languages provide syntactic sugar for chained comparisons.
For example in Python, `a < b < c` would be evaluated as `(a < b) and (b < c)`.
`CompareChain` was originally motivated by the following situation:

Elixir does not provide this.
Instead, `a < b < c` is evaluated as `(a < b) < c`.
Since `a < b` is a boolean, that's probably not what you want.
> You have an interval of time bounded by a two `%Date{}` structs: `start_date` and `end_date`.
> You want to know if some third `date` falls in that interval.
> How do you write this?
Further, operators like `<` do _structural_ comparison instead of _semantic_ comparison.
For most situations, you probably want to use `compare/2`.
From the [`Kernel` docs](https://hexdocs.pm/elixir/Kernel.html#module-structural-comparison):
In Elixir, we'd write this as follows:

```elixir
Date.compare(start_date, date) == :lt and
Date.compare(date, end_date) == :lt
```

<blockquote>
<details>
This is verbose and therefore a little hard to read.
It's also potentially incorrect.
What if `date` is considered "within" the interval even if it equals `start_date` or `end_date`?
To include the bounds in our comparison, we'd instead write the expression like this:

<summary>Show/Hide</summary>
```elixir
Date.compare(start_date, date) != :gt and
Date.compare(date, end_date) != :gt
```

The comparison functions in this module perform structural comparison.
This means structures are compared based on their representation and not on their semantic value.
This is specially important for functions that are meant to provide ordering, such as <code>&gt;/2</code>, <code>&lt;/2</code>, <code>&gt;=/2</code>, <code>&lt;=/2</code>, <code>min/2</code>, and <code>max/2</code>.
For example:
(We could have written `Date.compare(start_date, date) in [:lt, :eq]`, but `!= :gt` is faster.)

<pre>
<code>
~D[2017-03-31] > ~D[2017-04-01]
</code>
</pre>
In order to spot the difference between these two cases, you have to keep several things in mind:

returns <code>true</code> because structural comparison compares the <code>:day</code> field before <code>:month</code> or <code>:year</code>.
Therefore, when comparing structs, you often use the <code>compare/2</code> function made available by the structs modules themselves:
* The order of the arguments passed to `Date.compare/2`
* The specific comparison operators for each clause: `==` vs. `!=`
* The specific comparison atoms for each clause: `:lt` vs. `:gt`

<pre>
<code>
iex> Date.compare(~D[2017-03-31], ~D[2017-04-01])
:lt
</code>
</pre>
Since this is hard to read, it's easy to introduce bugs.
Contrast this with how you'd write the equivalent code in Python:

</details>
</blockquote>
```
start_date < date < end_date # excluding bounds
start_date <= date <= end_date # including bounds
```

This is much easier to read.
So why can't we write this in Elixir?
Two reasons:

* Structural comparison operators
* Chained vs. nested comparisons

### Structural comparison operators

Operators like `<` do _structural_ comparison instead of _semantic_ comparison.
From the [`Kernel` docs](https://hexdocs.pm/elixir/Kernel.html#module-structural-comparison):

The `compare/2` approach works well in many situations, but even moderately complicated logic can be cumbersome.
If we wanted the native equivalent of:
> ... **comparisons in Elixir are structural**, as it has the goal
of comparing data types as efficiently as possible to create flexible
and performant data structures. This distinction is specially important
for functions that provide ordering, such as `>/2`, `</2`, `>=/2`,
`<=/2`, `min/2`, and `max/2`. For example:
>
> ```elixir
> ~D[2017-03-31] > ~D[2017-04-01]
> ```
>
> will return `true` because structural comparison compares the `:day`
field before `:month` or `:year`. In order to perform semantic comparisons,
the relevant data-types provide a `compare/2` function, such as
`Date.compare/2`:
>
> ```elixir
> iex> Date.compare(~D[2017-03-31], ~D[2017-04-01])
> :lt
> ```
In other words, although `~D[2017-03-31] > ~D[2017-04-01]` is perfectly valid code, it does _not_ tell you if `~D[2017-03-31]` is a later date than `~D[2017-04-01]` like you might expect.
Instead, you need to use `Date.compare/2`.
### Chained vs. nested comparisons
Additionally, even if `~D[2017-03-31] > ~D[2017-04-01]` did do semantic comparison, you still couldn't write the interval check like you do in Python.
This is because in Python, an expression like `1 < 2 < 3` is syntactic sugar for `(1 < 2) and (2 < 3)`, aka a series of "chained" expressions.
Elixir does not provide an equivalent syntactic sugar.
Instead, `1 < 2 < 3` is evaluated as `(1 < 2) < 3`, aka a series of "nested" expressions.
Since `(1 < 2) < 3` simplifies to `true < 3`, that's probably not what you want!
Elixir will even warn you when you attempt an expression like that:
> warning: Elixir does not support nested comparisons. Something like
>
> x < y < z
>
> is equivalent to
>
> (x < y) < z
>
> which ultimately compares z with the boolean result of (x < y). Instead, consider joining together each comparison segment with an "and", for example,
>
> x < y and y < z
### CompareChain
`CompareChain` attempts to address both of these issues with the macro `CompareChain.compare?/2`.
Its job is to take code similar to how you'd like to write it and rewriting it to be semantically correct.
For our motivating example, we'd write this:
```elixir
iex> compare?(~D[2017-03-31] <= ~D[2017-04-01] < ~D[2017-04-02], Date)
import CompareChain
compare?(start_date < date < end_date, Date) # excluding bounds
compare?(start_date <= date <= end_date, Date) # including bounds
```
And at compile time, `CompareChain.compare?/2` rewrites those to be:

```elixir
# excluding bounds
Date.compare(start_date, date) == :lt and
Date.compare(date, end_date) == :lt

# including bounds
Date.compare(start_date, date) != :gt and
Date.compare(date, end_date) != :gt
```

This way your code is more readable while still remaining correct.

`CompareChain.compare?/1` is also available in case you only need chained comparison using the structural operators:

```elixir
compare?(1 < 2 < 3)
```

we'd have to write:
Though I find this case comes up less often.

### One last selling point

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

```elixir
iex> Date.compare(~D[2017-03-31], ~D[2017-04-01]) != :gt and Date.compare(~D[2017-04-01]), ~D[2017-04-02]) == :lt
compare?(a <= b, Date)
# vs.
Date.compare(a, b) != :gt
```

The goal of both `compare?/1` and `compare?/2` is to provide the syntactic sugar for chained comparisons.
With `compare?/2`, there is the added benefit of being able to use the structural comparison operators for semantic comparison.
(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.
Loading

0 comments on commit 4bc64df

Please sign in to comment.