Ecto not finding my constraints when inserting in SQLite

Issue with Foreign Key Constraint in Ecto Changeset

Hello everyone,

I’m encountering an issue with foreign_key_constraint in an Ecto changeset. Specifically, when I attempt to insert a record into my taxas table, I receive the following error:

[error] ** (Ecto.ConstraintError) constraint error when attempting to insert struct:

    * nil (foreign_key_constraint)

If you would like to stop this constraint violation from raising an
exception and instead add it as an error to your changeset, please
call `foreign_key_constraint/3` on your changeset with the constraint
`:name` as an option.

The changeset defined the following constraints:

    * "taxas_recinto_id_fkey" (foreign_key_constraint)
    * "taxas_servico_id_fkey" (foreign_key_constraint)

    (ecto 3.12.5) lib/ecto/repo/schema.ex:881: anonymous fn/4 in Ecto.Repo.Schema.constraints_to_errors/3
    (elixir 1.17.3) lib/enum.ex:1703: Enum."-map/2-lists^map/1-1-"/2
    (ecto 3.12.5) lib/ecto/repo/schema.ex:865: Ecto.Repo.Schema.constraints_to_errors/3
...

Problem Context

I have the following schema for taxas, and migrations:

defmodule Backend.Taxas.Taxa do
  use Ecto.Schema
  import Ecto.Changeset
  alias Backend.Recintos.Recinto
  alias Backend.Servicos.Servico
  alias Backend.Taxas.Valores

  @derive {Jason.Encoder, [except: [:__meta__, :recinto, :servico]]}
  schema "taxas" do
    belongs_to :recinto, Recinto
    belongs_to :servico, Servico
    field :tipo, Ecto.Enum, values: [:publica, :negociada]
    embeds_one :valores, Valores
    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(taxa, attrs) do
    taxa
    |> cast(attrs, [:servico_id, :recinto_id, :tipo])
    |> validate_required([:servico_id, :recinto_id, :tipo])
    |> cast_embed(:valores, [required: true])  # Fix here for embedding
    |> foreign_key_constraint(:servico_id, message: "Servico not found")
    |> foreign_key_constraint(:recinto_id, message: "Recinto not found")
  end
end
defmodule Backend.Repo.Migrations.CreateTaxas do
  use Ecto.Migration

  def change do
    create table(:taxas) do
      add :recinto_id, references(:recintos)
      add :servico_id, references(:servicos)
      add :tipo, :string
      add :valores, :map

      timestamps(type: :utc_datetime)
    end
  end
end
defmodule Backend.Repo.Migrations.CreateServicos do
  use Ecto.Migration

  def change do
    create table(:servicos) do
      add :recinto_id, references(:recintos)
      add :nome, :string
      add :obrigatorio, :boolean, default: false, null: false
      add :hasPeriodo, :boolean, default: false, null: false
      add :cobranca, :string
      add :aplicaTaxaMercadoria, :boolean, default: false, null: false
      add :aplicaTaxaContainer, :boolean, default: false, null: false
      add :valor, :float

      timestamps(type: :utc_datetime)
    end
  end
end

defmodule Backend.Repo.Migrations.CreateRecintos do
  use Ecto.Migration

  def change do
    create table(:recintos) do
      add :recintoId, :integer
      add :nome, :string

      timestamps(type: :utc_datetime)
    end
  end
end

In this setup, I have two foreign keys: servico_id and recinto_id. The foreign_key_constraint for these fields is supposed to handle constraint violations when the foreign keys don’t exist in the database.
What I’m Trying to Achieve

I want to catch foreign key violations and add them as errors to the changeset rather than having Ecto raise an exception.

When i insert then using previously added recinto and servico, the api returns successfully.

My question is why do i have that ‘nil’ in where the constraint name were supposed to be.

try adding
has_one
or
has_many

1 Like

Make sure your db has
Foreign-key constraints:
"taxas_recinto_id_fkey" FOREIGN KEY (recinto_id) REFERENCES recintos(id) "taxas_servico_id_fkey" FOREIGN KEY (servico_id) REFERENCES servicos(id)

for some reason the constraint name is different:
|> foreign_key_constraint(:recinto_id, name: "some_other_constraint_name", message: "Recinto not found") |> foreign_key_constraint(:servico_id, name: "another_constraint_name", message: "Servico not found")

1 Like

Pulling this query from my SQLite .db file

sqlite> SELECT tbl_name,sql FROM sqlite_master WHERE sql LIKE('%REFERENCES%');
servicos|CREATE TABLE "servicos" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "recinto_id" INTEGER CONSTRAINT "servicos_recinto_id_fkey" REFERENCES "recintos"("id"), "nome" TEXT, "obrigatorio" INTEGER DEFAULT false NOT NULL, "hasPeriodo" INTEGER DEFAULT false NOT NULL, "cobranca" TEXT, "aplicaTaxaMercadoria" INTEGER DEFAULT false NOT NULL, "aplicaTaxaContainer" INTEGER DEFAULT false NOT NULL, "valor" NUMERIC, "inserted_at" TEXT NOT NULL, "updated_at" TEXT NOT NULL)
taxas|CREATE TABLE "taxas" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "recinto_id" INTEGER CONSTRAINT "taxas_recinto_id_fkey" REFERENCES "recintos"("id"), "servico_id" INTEGER CONSTRAINT "taxas_servico_id_fkey" REFERENCES "servicos"("id"), "tipo" TEXT, "valores" TEXT, "inserted_at" TEXT NOT NULL, "updated_at" TEXT NOT NULL)

As you can see, in table “taxas” there’s the two constraints setted up, with the same name as stated in the trace…

Added has_many Taxas in Servico, still the same error

defmodule Backend.Servicos.Servico do
  use Ecto.Schema
  import Ecto.Changeset
  alias Backend.Recintos.Recinto


@derive {Jason.Encoder, except: [:__meta__, :recinto, :taxas]}
 schema "servicos" do
    belongs_to :recinto, Recinto
    has_many :taxas, Backend.Taxas.Taxa
    field :nome, :string
    field :obrigatorio, :boolean, default: false
    field :hasPeriodo, :boolean, default: false
    field :cobranca, :string
    field :aplicaTaxaMercadoria, :boolean, default: true
    field :aplicaTaxaContainer, :boolean, default: true
    field :valor, :float

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(servico, attrs) do
    servico
    |> cast(attrs, [:recinto_id, :nome, :obrigatorio, :hasPeriodo, :cobranca, :aplicaTaxaMercadoria, :aplicaTaxaContainer, :valor])
    |> foreign_key_constraint(:recinto_id)
    |> cast_assoc(:taxas, [required: :true])
    |> validate_required([:nome, :obrigatorio, :hasPeriodo, :cobranca, :aplicaTaxaMercadoria, :aplicaTaxaContainer, :valor])

  end
end

what does the struct you are trying to insert look like? Does it have a servico_id? It seems like it’s nil from the error message.

1 Like

Yea unfortunately @apoorv-2204 ’s advice is not correct, those do not make any difference here. I do wonder if SQLite is a factor here. I have used the foreign key constraint feature in Postgres with Ecto a lot and that works great.

1 Like

Had the same feeling, i think i’m gonna setup a postgress env to see if it makes any difference.

Here’s the struct of the request

{
  "taxa" : 
    {
        "recinto_id" : 1,
        "servico_id" : 2,
        "tipo": "publica",
        "valores": {
            "container": {
                "Carga Solta": null,
                "Flat Rack": null,
                "Normal": 1,
                "Open Top": null
            },
            "mercadoria": {
                "IMO": null,
                "Normal": null,
                "Oversize": null,
                "Oversize IMO": null,
                "Reefer": null
            }
        }
    }
}

There is no servico_id of 2 here, so the error is “correct” but not formatted correctly.

I went into the Ecto.ConstraintError to see what variable was passed into the msg, and when i tried to inspect it, it threw an error, so i think the error is in the somewhere in the middle of the trace. But debugging it would be too time consuming.

Here’s the code i analysed

defp constraints_to_errors(%{constraints: user_constraints, errors: errors} = changeset, action, constraints) do
    constraint_errors =
      Enum.map constraints, fn {type, constraint} ->
        user_constraint =
          Enum.find(user_constraints, fn c ->
            case {c.type, c.constraint,  c.match} do
              {^type, ^constraint, :exact} -> true
              {^type, cc, :suffix} -> String.ends_with?(constraint, cc)
              {^type, cc, :prefix} -> String.starts_with?(constraint, cc)
              {^type, %Regex{} = r, _match} -> Regex.match?(r, constraint)
              _ -> false
            end
          end)

        case user_constraint do
          %{field: field, error_message: error_message, error_type: error_type} ->
            {field, {error_message, [constraint: error_type, constraint_name: constraint]}}
          nil ->
            raise Ecto.ConstraintError, action: action, type: type,
                                        constraint: constraint, changeset: changeset
        end
      end
defmodule Ecto.ConstraintError do
  defexception [:type, :constraint, :message]

  def exception(opts) do
    type = Keyword.fetch!(opts, :type)
    constraint = Keyword.fetch!(opts, :constraint)
    changeset = Keyword.fetch!(opts, :changeset)
    action = Keyword.fetch!(opts, :action)

    constraints =
      case changeset.constraints do
        [] ->
          "The changeset has not defined any constraint."

        constraints ->
          "The changeset defined the following constraints:\n\n" <>
            Enum.map_join(
              constraints,
              "\n",
              &"    * #{inspect(&1.constraint)} (#{&1.type}_constraint)"
            )
      end

    msg = """
    constraint error when attempting to #{action} struct:

        * #{inspect(constraint)} (#{type}_constraint)

    If you would like to stop this constraint violation from raising an
    exception and instead add it as an error to your changeset, please
    call `#{type}_constraint/3` on your changeset with the constraint
    `:name` as an option.

    #{constraints}
    """

    %__MODULE__{message: msg, type: type, constraint: constraint}
  end
end

Inside exception(opts), constraint is nil, but when did it set to nil i didn’t figure it out.

Here’s the full trace of the problem

[error] ** (Ecto.ConstraintError) constraint error when attempting to insert struct:

    * nil (foreign_key_constraint)

If you would like to stop this constraint violation from raising an
exception and instead add it as an error to your changeset, please
call `foreign_key_constraint/3` on your changeset with the constraint
`:name` as an option.

The changeset defined the following constraints:

    * "taxas_recinto_id_fkey" (foreign_key_constraint)
    * "taxas_servico_id_fkey" (foreign_key_constraint)

    (ecto 3.12.5) lib/ecto/repo/schema.ex:881: anonymous fn/4 in Ecto.Repo.Schema.constraints_to_errors/3
    (elixir 1.17.3) lib/enum.ex:1703: Enum."-map/2-lists^map/1-1-"/2
    (ecto 3.12.5) lib/ecto/repo/schema.ex:865: Ecto.Repo.Schema.constraints_to_errors/3
    (ecto 3.12.5) lib/ecto/repo/schema.ex:842: Ecto.Repo.Schema.apply/4
    (ecto 3.12.5) lib/ecto/repo/schema.ex:416: anonymous fn/15 in Ecto.Repo.Schema.do_insert/4
    (ecto 3.12.5) lib/ecto/repo/schema.ex:1099: anonymous fn/3 in Ecto.Repo.Schema.wrap_in_transaction/6
    (ecto_sql 3.12.1) lib/ecto/adapters/sql.ex:1400: anonymous fn/3 in Ecto.Adapters.SQL.checkout_or_transaction/4
    (db_connection 2.7.0) lib/db_connection.ex:1756: DBConnection.run_transaction/4
    (backend 0.1.0) lib/backend_web/controllers/taxa_controller.ex:16: BackendWeb.TaxaController.create/2
    (backend 0.1.0) lib/backend_web/controllers/taxa_controller.ex:1: BackendWeb.TaxaController.action/2
    (backend 0.1.0) lib/backend_web/controllers/taxa_controller.ex:1: BackendWeb.TaxaController.phoenix_controller_pipeline/2
    (phoenix 1.7.18) lib/phoenix/router.ex:484: Phoenix.Router.__call__/5
    (backend 0.1.0) lib/backend_web/endpoint.ex:1: BackendWeb.Endpoint.plug_builder_call/2
    (backend 0.1.0) deps/plug/lib/plug/debugger.ex:136: BackendWeb.Endpoint."call (overridable 3)"/2
    (backend 0.1.0) lib/backend_web/endpoint.ex:1: BackendWeb.Endpoint.call/2
    (phoenix 1.7.18) lib/phoenix/endpoint/sync_code_reload_plug.ex:22: Phoenix.Endpoint.SyncCodeReloadPlug.do_call/4
    (bandit 1.6.1) lib/bandit/pipeline.ex:127: Bandit.Pipeline.call_plug!/2
    (bandit 1.6.1) lib/bandit/pipeline.ex:36: Bandit.Pipeline.run/4
    (bandit 1.6.1) lib/bandit/http1/handler.ex:12: Bandit.HTTP1.Handler.handle_data/3
    (bandit 1.6.1) lib/bandit/delegating_handler.ex:18: Bandit.DelegatingHandler.handle_data/3

There is no servico_id of 2 here, so the error is “correct” but not formatted correctly.

Neat, thanks for the extra info :+1:.

So it tried finding the servico, couldn’t do it (since it doesn’t exist) and reported that constraint error:

[error] ** (Ecto.ConstraintError) constraint error when attempting to insert struct:

    * nil (foreign_key_constraint)

But it shouldn’t, while correct, not the root cause.

@benwilson512 seems to be in the right direction here I think. If a pg example fails the same way, maybe an opening an issue about it would be the next move.

1 Like

@vitordahoraa, this test passes: ef68425/test/acceptance_test.exs at main · pdgonzalez872/ef68425 · GitHub

@benwilson512 seems to be right :slight_smile:

1 Like

Changed the repo to postgres, and now the return is correct.


{
    "errors": {
        "servico_id": [
            "Servico not found"
        ]
    }
}

I’m unsure how to continue. I think the next step should be to create a issue to fix this in the SQLite adapter, and continue using postgres in my project.

If so, i’m very recent in the open source comunity, and don’t know the next steps. Any help is apreciated, as well as the help i got so far.

unsure how to continue

Seems like this is documented too: ecto_sqlite3/lib/ecto/adapters/sqlite3.ex at main · elixir-sqlite/ecto_sqlite3 · GitHub

Is pg an option in your project? I hope so :slight_smile:

3 Likes

I remember there was some limitation on SQLite side related to foreign key constraints. Perhaps Ecto.Adapters.SQLite3 — Ecto SQLite3 v0.18.0 and Support Ecto.Changeset.foreign_key_constraint/3 ? · Issue #42 · elixir-sqlite/ecto_sqlite3 · GitHub ?

2 Likes

Sure is, i set up a SQLite DB for practical purposes, but PostgresSQL is very welcomed in this project

2 Likes

Shoot, as always, someone already got this problem lmao. Shame, i did a deep research on this issue and found nothing about this error, but it was already documented. Guess it was a fault on my part…

Thanks for sharing.

2 Likes