Exclusive OR in Ecto: validating that only one of two fields is given

I have a table for a collection of tasks. Only one of the fields :expiration_time and :expiration_all_day? may have a value (i.e. not be null/nil). Also, at least one should be provided.

Was making a start with implementing this, but can’t figure out how to use multiple fields in create constraint. I expected to be able to do this:

defmodule TodayTodo.Repo.Migrations.CreateTasks do
  use Ecto.Migration

  def change do
    create table(:tasks, primary_key: false) do
      add :id, :uuid, primary_key: true
      add :description, :string
      add :expiration_date, :date
      add :expiration_time, :time
      add :expiration_all_day?, :boolean # Prob throws

      timestamps()
    end

    create(
      constraint(
        :tasks,
        :validate_time_or_all_day,
        check: "(expiration_time NOT NULL) OR (expiration_all_day? NOT NULL)"
      )
    )
  end
end

defmodule TodayTodo.Tasks.Task do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}

  schema "tasks" do
    field :description, :string
    field :expiration_date, :date
    field :expiration_time, :time
    field :expiration_all_day?, :boolean

    timestamps()
  end

  @doc false
  def changeset(task, attrs) do
    task
    |> cast(attrs, [:id, :description, :expiration_date, :expiration_time, :expiration_all_day?])
    |> validate_required([:description, :expiration_date])
    |> check_constraint(:expiration_time, name: :validate_time_or_all_day)
  end
end

But I get:

20:19:10.714 [info]  create table tasks

20:19:10.718 [info]  create check constraint validate_time_or_all_day on table tasks
** (Postgrex.Error) ERROR 42601 (syntax_error) syntax error at or near "NOT"

    query: ALTER TABLE "tasks" ADD CONSTRAINT "validate_time_or_all_day" CHECK ((expiration_time IS NOT NULL) OR (expiration_all_day? IS NOT NULL))
    (ecto_sql 3.8.3) lib/ecto/adapters/sql.ex:932: Ecto.Adapters.SQL.raise_sql_call_error/1
    (elixir 1.13.3) lib/enum.ex:1593: Enum."-map/2-lists^map/1-0-"/2
    (ecto_sql 3.8.3) lib/ecto/adapters/sql.ex:1024: Ecto.Adapters.SQL.execute_ddl/4
    (ecto_sql 3.8.3) lib/ecto/migration/runner.ex:352: Ecto.Migration.Runner.log_and_execute_ddl/3
    (ecto_sql 3.8.3) lib/ecto/migration/runner.ex:117: anonymous fn/6 in Ecto.Migration.Runner.flush/0
    (elixir 1.13.3) lib/enum.ex:2396: Enum."-reduce/3-lists^foldl/2-0-"/3
    (ecto_sql 3.8.3) lib/ecto/migration/runner.ex:116: Ecto.Migration.Runner.flush/0
    (ecto_sql 3.8.3) lib/ecto/migration/runner.ex:289: Ecto.Migration.Runner.perform_operation/3

What am I missing?

EDIT: I changed the code blocks above, because I copied an old version of my code initially.

The second argument to constraint should be a string or atom for the constraint name, but you’re passing a list of atoms.

1 Like

My bad, I copied an old version of my code into this post. I have fixed that now.

So it’s not about the name argument needing to be a string or atom, but rather: I can’t quite figure out why I get the error ** (Postgrex.Error) ERROR 42601 (syntax_error) syntax error at or near "NOT" and what field I am supposed to provide in |> check_constraint(:expiration_time, name: :validate_time_or_all_day). Can check_constraint only take one field?

I tried other checks also but get similar errors. For example:

check: "num_nonnulls(expiration_time, expiration_all_day?) = 1"

You need to escape field names that have special characters like ? - Ecto normally takes care of this, but the SQL given to check is passed through verbatim.

2 Likes

Did you fix this? There’s probably at least two ways of doing it IIRC.

1 Like

Finally got time to look at it again.

To handle the error I got when migrating, I tried to escape the ? from the column expiration_all_day?. I tried so by doing check: "num_nonnulls(expiration_time, expiration_all_day\?) = 1". That didn’t work. Then I tried using single quotes and that did the trick.

    create(
      constraint(
        :tasks,
        :validate_time_or_all_day,
        check: "num_nonnulls('expiration_time', 'expiration_all_day?') = 1"
      )
    )

In my changeset I have:

  def changeset(task, attrs) do
    task
    |> cast(attrs, [:id, :description, :expiration_date, :expiration_time, :expiration_all_day?])
    |> validate_required([:description, :expiration_date])
    |> check_constraint(:expiration_time, name: :validate_time_or_all_day, message: "select a time or 'all day', but not both")
  end

So I only mention one variable in check_constraint/3, not both. It works but also seems a bit odd.

But most importantly, what I thought would work for the exclusive or does not work: check: "num_nonnulls('col1', col2') = 1". But that probably has to do with that one of the columns is boolean and by default false. So never equal to null.

1 Like

To follow up.

It indeed had to do with overlooking the default false of my boolean column. So it was an easy fix to make num_nonnulls/num_nulls work:

    create(
      constraint(
        :tasks,
        :validate_time_or_all_day,
        check: "num_nulls(time, NULLIF(all_day, false)) = 1"
      )
    )

As you can see I got rid of the question mark in my column name all together. Both cases didn’t work: NULLIF(all_day\?, false)) = 1 or NULLIF('all_day?', false)) = 1.

1 Like

Yep, I was about to suggest you use CASE or NULLIF last night but was on the phone.

Glad you made it work!

1 Like

Thanks for the support :nerd_face:

A simple solution would be to change the design completely and simplify:

  • Store expiration_date disallowing NULL
  • Store expiration_time which is a time and can be NULL, if the value is NULL then the task is all day
2 Likes

or taking it a step further… have expiration_datetime, and if it’s all day, set the time part to 23:59:59 or similar… Then there would be only one place to look to see whether the task has expired.

Negative

23:59:59 is a naturally occurring value and should not be used as a magic value

Depending on your time grain (in this case 1s), it would be the last moment of the day, so is a valid solution provided it isn’t important whether you knew a time was selected or not.

No

Expiration in this case is a moment in time and 23:59:59 is still a valid moment in time

Albeit most of the time harmless, it is not ideal to use such a value as a magic value that implies additional meaning