Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Checking for nil in dynamic ecto queries

livebooks/dynamic_ecto_queries.livemd

Checking for nil in dynamic ecto queries

Mix.install([
  {:ecto_sql, "~> 3.9"},
  {:ecto, "~> 3.9"},
  {:kino, "~> 0.8.1"},
  {:ecto_sqlite3, "~> 0.9.1"}
])

Try it yourself!

Run in Livebook

In this Livebook we will do dynamic ecto queries for filtering users by age and show you how to check for nil in a bad, ugly, and good way. Enjoy 🙂

Database setup

Let’s set up our database. We chose SQLite3 because it works out of the box in the Livebook environment.

defmodule User do
  use Ecto.Schema
  @primary_key false
  schema "users" do
    field(:name, :string)
    field(:age, :integer)
  end
end

defmodule Repo do
  use Ecto.Repo, adapter: Ecto.Adapters.SQLite3, otp_app: :nothing
end

case Ecto.Repo.all_running() do
  [_ | _] -> Repo.stop()
  _ -> []
end

Repo.start_link(database: "anti-aging.db")

[
  "DROP TABLE IF EXISTS users",
  "CREATE TABLE users (name STRING, age INT)"
]
|> Enum.map(&Ecto.Adapters.SQL.query!(Repo, &1, []))

users = [
  %{name: "👶", age: 0},
  %{name: "🦖", age: 65_000_000},
  %{name: "🤠", age: 92},
  %{name: "👧", age: 12},
  %{name: "👩🏻", age: 42},
  %{name: "🦄", age: 101}
]

Repo.insert_all("users", users, [])

The happy path

The query works perfectly fine if no variable has the value of nil.

import Ecto.Query

min_age = 21
max_age = 55

query = from(u in User, where: u.age >= ^min_age and u.age <= ^max_age)

Repo.all(query) |> Kino.DataTable.new()

Checking for nil: the bad way

defmodule BadExample do
  import Ecto.Query

  def by_age(min_age, max_age) do
    query = from(u in User, where: u.age > ^min_age and u.age < ^max_age)
    Repo.all(query)
  end
end

As long as we call the function with no nil value, it works perfectly fine.

BadExample.by_age(21, 55) |> Kino.DataTable.new()

But as soon as we call the function with nil, it will throw an error 😨

BadExample.by_age(nil, 55) |> Kino.DataTable.new()

Checking for nil: the ugly way

As we have seen above, not checking for nil at all is a bad way to go. Let’s check for nil for every possible case.

  • min_age = nil
  • max_age = nil
  • min_age = nil & max_age = nil
defmodule UglyExample do
  import Ecto.Query

  def by_age(min_age, max_age) when is_nil(min_age) and is_nil(max_age) do
    query = from(u in User)
    Repo.all(query)
  end

  def by_age(min_age, max_age) when is_nil(min_age) do
    query = from(u in User, where: u.age <= ^max_age)
    Repo.all(query)
  end

  def by_age(min_age, max_age) when is_nil(max_age) do
    query = from(u in User, where: u.age >= ^min_age)
    Repo.all(query)
  end

  def by_age(min_age, max_age) do
    query = from(u in User, where: u.age <= ^max_age and u.age > ^min_age)
    Repo.all(query)
  end
end
UglyExample.by_age(21, 55) |> Kino.DataTable.new()
UglyExample.by_age(nil, 55) |> Kino.DataTable.new()

This works fine, but it’s a cumbersome and unelegant way. Elixir can do better, and we can, too 🤩

Checking for nil: the good way

Here, we are implenting two helper functions:

  • maybe_add_min_age
  • maybe_add_max_age

Inside those helper functions, we do our nil checks (loc: 13, loc: 16).

Before we will do the actual query on the database, we pipe our query through those helper functions.

defmodule GoodExample do
  import Ecto.Query

  def by_age(min_age, max_age) do
    query = from(u in User)

    query
    |> maybe_add_min_age(min_age)
    |> maybe_add_max_age(max_age)
    |> Repo.all()
  end

  defp maybe_add_min_age(query, nil), do: query
  defp maybe_add_min_age(query, min_age), do: where(query, [u], u.age >= ^min_age)

  defp maybe_add_max_age(query, nil), do: query
  defp maybe_add_max_age(query, max_age), do: where(query, [u], u.age <= ^max_age)
end
GoodExample.by_age(21, 55) |> Kino.DataTable.new()
GoodExample.by_age(nil, 55) |> Kino.DataTable.new()

For us, this way of coding looks more explicit and way more elegant 🥳 What do you say?