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()
1 Like

Thank you for the detailed post.

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

1 Like

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?

2 Likes

I hadn’t tried that yet.

From iex -S mix phx.server the constraints and triggers all work as expected. So indeed it seems to be an issue with the database sandbox.

The constraints and triggers work as expected outside of the test environment.

Any suggestions how I can best test these database behaviors? My tree model is an important building block for the rest of the model and application. Want to leave as little as possible to faith and make sure I can find issues with the tree model quickly.

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.

1 Like