coen.bakker

coen.bakker

Ecto.Migration: CHECK constraint and TRIGGER not working

TLDR: Database CHECK constraint and TRIGGER not working when inserting data with Ecto, but do work when inserting data with PSQL (on same database).

I ran into a problem while setting up a database migration in a Phoenix project. I wanted to add some CHECK constraints and TRIGGERs, like what I have been doing from my terminal for another, Postgres-only project. However, some of the CHECK constraints and TRIGGERs that I have added do not work as expected when I use Ecto to insert data into my database. However, when I use PSQL from my terminal on the same database, all constraints and triggers work as expected.

Here is an example of one of the culprit constraints.

create constraint(:node, :no_node_reference_to_self, check: "id <> node_id")

In my migration file you can find it under the :node table.

defmodule MyApp.Repo.Migrations.AddTreeModel do
  use Ecto.Migration

  def change do
    create table(:tree) do
      add :title, :string, size: 40
      timestamps()
    end

    create table(:node) do
      add :tree_id, references(:tree)
      add :node_id, references(:node)
      timestamps()
    end
    create constraint(:node, :one_outgoing_edge, check: "num_nonnulls(tree_id, node_id) = 1")
    create constraint(:node, :no_node_reference_to_self, check: "id <> node_id")

    create table(:stop) do
      add :node_id, references(:node), null: false
      timestamps()
    end
    create unique_index(:stop, [:node_id])

    create table(:constrainer) do
      add :tree_id, references(:tree)
      add :node_id, references(:node)
      add :branching, :boolean, null: false, default: false
      add :private, :boolean, null: false, default: true
      timestamps()
    end
    create constraint(:constrainer, :one_outgoing_edge, check: "num_nonnulls(tree_id, node_id) = 1")
    create constraint(:constrainer, :tree_constrainer_only_option, check: "NOT (private AND tree_id IS NULL)")
    create unique_index(:constrainer, [:tree_id])
    create unique_index(:constrainer, [:node_id])

    # Add PostgreSQL triggers to ensure that a node is not referenced by both a node and a stop
    execute create_trigger_function_for_node(), drop_trigger_function_for_node()
    execute create_trigger_for_node(), drop_trigger_for_node()
    execute create_trigger_function_for_stop(), drop_trigger_function_for_stop()
    execute create_trigger_for_stop(), drop_trigger_for_stop()
  end

  defp create_trigger_function_for_node do
    """
    CREATE OR REPLACE FUNCTION validate_node_not_referenced_by_stop()
    RETURNS TRIGGER AS $$
    DECLARE
      referenced_by_stop BOOLEAN;
    BEGIN
      SELECT INTO referenced_by_stop EXISTS(SELECT FROM stop WHERE node_id = NEW.node_id);
      IF referenced_by_stop THEN
        RAISE EXCEPTION 'You are trying to reference from a node to the node with id %. However, that node is already referenced by a stop. A node cannot simultaneously be referenced by a node and a stop.', NEW.node_id;
      END IF;
      RETURN NEW;
    END;
    $$ LANGUAGE plpgsql;
    """
  end

  defp drop_trigger_function_for_node do
    """
    DROP FUNCTION validate_node_not_referenced_by_stop();
    """
  end

  defp create_trigger_for_node do
    """
    CREATE TRIGGER validate_node_not_referenced_by_stop
    BEFORE INSERT OR UPDATE ON node
    FOR EACH ROW EXECUTE PROCEDURE validate_node_not_referenced_by_stop();
    """
  end

  defp drop_trigger_for_node do
    """
    DROP TRIGGER validate_node_not_referenced_by_stop ON node;
    """
  end

  defp create_trigger_function_for_stop do
    """
    CREATE OR REPLACE FUNCTION validate_node_not_referenced_by_node()
    RETURNS TRIGGER AS $$
    DECLARE
      referenced_by_node BOOLEAN;
    BEGIN
      SELECT INTO referenced_by_node EXISTS(SELECT FROM node WHERE node_id = NEW.node_id);
      IF referenced_by_node THEN
        RAISE EXCEPTION 'You are trying to reference from a stop to the node with id %. However, that node is already referenced by a node. A node cannot simultaneously be referenced by a node and a stop.', NEW.node_id;
      END IF;
      RETURN NEW;
    END;
    $$ LANGUAGE plpgsql;
    """
  end

  defp drop_trigger_function_for_stop do
    """
    DROP FUNCTION validate_node_not_referenced_by_node();
    """
  end

  defp create_trigger_for_stop do
    """
    CREATE TRIGGER validate_node_not_referenced_by_node
    BEFORE INSERT OR UPDATE ON stop
    FOR EACH ROW EXECUTE PROCEDURE validate_node_not_referenced_by_node();
    """
  end

  defp drop_trigger_for_stop do
    """
    DROP TRIGGER validate_node_not_referenced_by_node ON stop;
    """
  end
end

I wrote some tests. The last seven tests correspond to the constraints and triggers that are not working as expected in my Phoenix project. I have manually tested the same scenarios in my terminal with PSQL on the same database. All the manual tests passed.

defmodule MyApp.TreeModelTest do
  use MyApp.DataCase
  alias MyApp.Repo
  alias MyApp.TreeModel.{Tree, Node, Stop, Constrainer}

  test "successfully insert tree with title" do
    assert {:ok, _} = %Tree{title: "Tree Title"} |> Repo.insert()
  end

  test "successfully insert tree without title" do
    assert {:ok, _} = %Tree{} |> Repo.insert()
  end

  test "successfully insert node with tree reference" do
    tree = Repo.insert!(%Tree{})
    assert {:ok, _} = %Node{tree_id: tree.id} |> Repo.insert()
  end

  test "successfully insert node with node reference" do
    tree = Repo.insert!(%Tree{})
    root_node = Repo.insert!(%Node{tree_id: tree.id})
    assert {:ok, _} = %Node{node_id: root_node.id} |> Repo.insert()
  end

  test "successfully insert a node that is referenced by multiple nodes" do
    tree = Repo.insert!(%Tree{})
    root_node = Repo.insert!(%Node{tree_id: tree.id})
    assert {:ok, _} = %Node{node_id: root_node.id} |> Repo.insert()
    assert {:ok, _} = %Node{node_id: root_node.id} |> Repo.insert()
  end

  test "successfully insert a tree that is references by multiple nodes" do
    tree = Repo.insert!(%Tree{})
    assert {:ok, _} = %Node{tree_id: tree.id} |> Repo.insert()
    assert {:ok, _} = %Node{tree_id: tree.id} |> Repo.insert()
  end

  test "successfully insert stop that references a node" do
    tree = Repo.insert!(%Tree{})
    root_node = Repo.insert!(%Node{tree_id: tree.id})
    assert {:ok, _} = %Stop{node_id: root_node.id} |> Repo.insert()
  end

  test "fail to insert node without tree and node reference" do
    tree = Repo.insert!(%Tree{})
    root_node = Repo.insert!(%Node{tree_id: tree.id})
    catch_error %Node{tree_id: tree.id, node_id: root_node.id} |> Repo.insert()
  end

  test "fail to insert stop that references node that is already referenced by a stop" do
    tree = Repo.insert!(%Tree{})
    root_node = Repo.insert!(%Node{tree_id: tree.id})
    Repo.insert!(%Stop{node_id: root_node.id})
    catch_error Repo.insert!(%Stop{node_id: root_node.id}) |> IO.inspect()
  end

  # Not failing in PSQL, but failing in Ecto
  test "fail to insert node that references itself" do
    tree = Repo.insert!(%Tree{})
    node = Repo.insert!(%Node{tree_id: tree.id})
    query = from(
      n in Node,
      where: n.id == ^node.id,
      update: [set: [tree_id: nil, node_id: ^node.id]])

    catch_error Repo.update_all(query, [])
  end

 # Not failing in PSQL, but failing in Ecto
  test "fail to insert node that references node that is already referenced by a stop" do
    tree = Repo.insert!(%Tree{})
    root_node = Repo.insert!(%Node{tree_id: tree.id})
    Repo.insert!(%Stop{node_id: root_node.id})
    catch_error %Node{node_id: root_node.id} |> Repo.insert()
  end

 # Not failing in PSQL, but failing in Ecto
  test "fail to insert stop that references node that is already referenced by a node" do
    tree = Repo.insert!(%Tree{})
    root_node = Repo.insert!(%Node{tree_id: tree.id})
    %Node{node_id: root_node.id} |> Repo.insert()
    catch_error Repo.insert!(%Stop{node_id: root_node.id})
  end

 # Not failing in PSQL, but failing in Ecto
  test "successfully insert node constrainer with branching" do
    tree = Repo.insert!(%Tree{})
    root_node = Repo.insert!(%Node{tree_id: tree.id})
    assert {:ok, _} = %Constrainer{node_id: root_node.id, branching: true} |> Repo.insert()
  end

 # Not failing in PSQL, but failing in Ecto
  test "successfully insert node constrainer without branching" do
    tree = Repo.insert!(%Tree{})
    root_node = Repo.insert!(%Node{tree_id: tree.id})
    assert {:ok, _} = %Constrainer{node_id: root_node.id, branching: false} |> Repo.insert()
  end

  test "fail to insert node constrainer with creator_only" do
    tree = Repo.insert!(%Tree{})
    root_node = Repo.insert!(%Node{tree_id: tree.id})
    catch_error %Constrainer{node_id: root_node.id, creator_only: true} |> Repo.insert()
  end

 # Not failing in PSQL, but failing in Ecto
  test "successfully insert node constrainer without creator_only" do
    tree = Repo.insert!(%Tree{})
    root_node = Repo.insert!(%Node{tree_id: tree.id})
    assert {:ok, _} = %Constrainer{node_id: root_node.id, creator_only: false} |> Repo.insert()
  end
end

Any ideas what could be the problem?

I have tried rolling back the migration and migrating again in mix_env=test. And dropping the database and migrating from scratch. That did not work.

When checking my database with PSQL I do see all the constraints and triggers in my tables. For example the node table.

                                          Table "public.node"
   Column    |              Type              | Collation | Nullable |             Default
-------------+--------------------------------+-----------+----------+----------------------------------
 id          | bigint                         |           | not null | nextval('node_id_seq'::regclass)
 tree_id     | bigint                         |           |          |
 node_id     | bigint                         |           |          |
 inserted_at | timestamp(0) without time zone |           | not null |
 updated_at  | timestamp(0) without time zone |           | not null |
Indexes:
    "node_pkey" PRIMARY KEY, btree (id)
Check constraints:
    "no_node_reference_to_self" CHECK (id <> node_id)
    "one_outgoing_edge" CHECK (num_nonnulls(tree_id, node_id) = 1)
Foreign-key constraints:
    "node_node_id_fkey" FOREIGN KEY (node_id) REFERENCES node(id)
    "node_tree_id_fkey" FOREIGN KEY (tree_id) REFERENCES tree(id)
Referenced by:
    TABLE "constrainer" CONSTRAINT "constrainer_node_id_fkey" FOREIGN KEY (node_id) REFERENCES node(id)
    TABLE "node" CONSTRAINT "node_node_id_fkey" FOREIGN KEY (node_id) REFERENCES node(id)
    TABLE "stop" CONSTRAINT "stop_node_id_fkey" FOREIGN KEY (node_id) REFERENCES node(id)
Triggers:
    validate_node_not_referenced_by_stop BEFORE INSERT OR UPDATE ON node FOR EACH ROW EXECUTE FUNCTION validate_node_not_referenced_by_stop()

Most Liked

D4no0

D4no0

I have a feeling this might be related with ecto sandbox, as transactions never get commited. Can you confirm that this doesn’t work outside of test environment?

dimitarvp

dimitarvp

Thank you for the detailed post.

Have you tried inserting a self-referencing node from your Elixir code and then inspecting it inside psql?

D4no0

D4no0

Disable or manage the sandbox reset manually, take a look at DataCase file generated by phoenix, there should be a setup block that resets the database on each new test.

Where Next?

Popular in Questions Top

aadeshere1
I have a another noob question about loop. Since elixir is immutable, while loop is not directly possible. total = 10 while total != 0 ...
New
New
mcarvalho
What is the difference between System.get_env and Application.get_env? For example, what are best practices to use one versus another.
New
greenz1
I have a phoenix application from which a user can download multiple(5-6) files of size 1MB. I couldn’t find anything related to sending ...
New
mgjohns61585
Could someone help me? I’m making my first elixir program, number guessing game. I can’t figure out how to convert the user’s guess from ...
New
vac
Hi, I’m quite new in Elixir and I’m trying to format a string to a PEM format. I have the certificate value like MIIDBTCCAe2...... and I...
New
Emily
I have VueJS GUIs with the project generated using Webpack. I have Elixir modules that will need to be used by the VueJS GUIs. I forese...
New
vonH
When I run the Plug and I recompile I wind up having to use Ctrl C to quit iex and start again. Witht the help of rlwrap I can use the cu...
New
jaysoifer
Is there a way to rollback a specific migration and only that one (“skipping” all the other ones)? Would mix ecto.rollback -v 200809061...
New
vegabook
I’m brand new to Phoenix and I have stripped one of the demo applications to the bone. I just want to get an svg up on the screen. Here i...
New

Other popular topics Top

aadeshere1
I have a another noob question about loop. Since elixir is immutable, while loop is not directly possible. total = 10 while total != 0 ...
New
siddhant3030
Hi, I have to write a raw query for one of my project. But till now I have used ecto queries and don’t have much experience writing raw ...
New
baxterw3b
Hi guys, i’m new in the Elixir world, and i have to say, that i love it! i’m having some problem to understand anonymous functions with ...
New
jerry
Good day to you all. I have been struggling to get a query involving like and ilike to work. Can anyone assist me on this, please? pro...
New
AngeloChecked
What learn first? Rust or Elixir Hi Elixir community! I’m here because i want learn a new language. I’m a junior developer and mainly i ...
New
gausby
I asked this very same question on twitter and got some interesting feedback, but I thought it would be a good question to ask here as we...
1207 39467 209
New
jay1
Why is it that the mnesia database isn’t the most preferred database for use in Elixir/Phoenix?
New
sergio_101
I am VERY much an elixir newbie. I have taken one elixir course and one phoenix course on Udemy. During that course, I saw the instructor...
New
komlanvi
Hi everyone, I was playing with phoenix liveView but I run into an issue. I have a form and want to validate each input text when the te...
New
axelson
This post is a wiki (feel free to hit the edit button near the bottom right of this post to add your own changes!) This post collects co...
239 48342 226
New

Latest on Elixir Forum

Elixir Forum

We're in Beta

About us Mission Statement