From 4a3f119ddb18f073d2f3f72060d231b832895fac Mon Sep 17 00:00:00 2001 From: Peter Mueller <6015288+petermueller@users.noreply.github.com> Date: Thu, 11 Jul 2024 01:54:01 -0400 Subject: [PATCH 1/8] allow `:constraint_handler` option defaults to existing adapter connection module --- integration_test/myxql/constraints_test.exs | 186 +++++++++++++++++++- integration_test/myxql/test_helper.exs | 7 +- lib/ecto/adapters/myxql.ex | 2 +- lib/ecto/adapters/sql.ex | 12 +- 4 files changed, 194 insertions(+), 13 deletions(-) diff --git a/integration_test/myxql/constraints_test.exs b/integration_test/myxql/constraints_test.exs index 2323823f..00cc0568 100644 --- a/integration_test/myxql/constraints_test.exs +++ b/integration_test/myxql/constraints_test.exs @@ -4,6 +4,29 @@ defmodule Ecto.Integration.ConstraintsTest do import Ecto.Migrator, only: [up: 4] alias Ecto.Integration.PoolRepo + defmodule CustomConstraintHandler do + @quotes ~w(" ' `) + + # An example of a custom handler a user might write + def to_constraints(%MyXQL.Error{mysql: %{name: :ER_SIGNAL_EXCEPTION}, message: message}, opts) do + # Assumes this is the only use-case of `ER_SIGNAL_EXCEPTION` the user has implemented custom errors for + with [_, quoted] <- :binary.split(message, "Overlapping values for key "), + [_, index | _] <- :binary.split(quoted, @quotes, [:global]) do + [exclusion: strip_source(index, opts[:source])] + else + _ -> [] + end + end + + def to_constraints(err, opts) do + # Falls back to default `ecto_sql` handler for all others + Ecto.Adapters.MyXQL.Connection.to_constraints(err, opts) + end + + defp strip_source(name, nil), do: name + defp strip_source(name, source), do: String.trim_leading(name, "#{source}.") + end + defmodule ConstraintMigration do use Ecto.Migration @@ -21,6 +44,50 @@ defmodule Ecto.Integration.ConstraintsTest do end end + defmodule ProcedureEmulatingConstraintMigration do + use Ecto.Migration + + @table_name :constraints_test + + def up do + insert_trigger_sql = trigger_sql(@table_name, "INSERT") + update_trigger_sql = trigger_sql(@table_name, "UPDATE") + + drop_triggers(@table_name) + repo().query!(insert_trigger_sql) + repo().query!(update_trigger_sql) + end + + def down do + drop_triggers(@table_name) + end + + defp trigger_sql(table_name, before_type) do + ~s""" + CREATE TRIGGER #{table_name}_#{String.downcase(before_type)}_overlap + BEFORE #{String.upcase(before_type)} + ON #{table_name} FOR EACH ROW + BEGIN + DECLARE v_rowcount INT; + DECLARE v_msg VARCHAR(200); + + SELECT COUNT(*) INTO v_rowcount FROM #{table_name} + WHERE (NEW.from <= `to` AND NEW.to >= `from`); + + IF v_rowcount > 0 THEN + SET v_msg = CONCAT('Overlapping values for key \\'#{table_name}.cannot_overlap\\''); + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = v_msg, MYSQL_ERRNO = 1644; + END IF; + END; + """ + end + + defp drop_triggers(table_name) do + repo().query!("DROP TRIGGER IF EXISTS #{table_name}_insert_overlap") + repo().query!("DROP TRIGGER IF EXISTS #{table_name}_update_overlap") + end + end + defmodule Constraint do use Ecto.Integration.Schema @@ -31,12 +98,23 @@ defmodule Ecto.Integration.ConstraintsTest do end end + defmodule CustomConstraint do + use Ecto.Integration.Schema + + schema "procedure_constraints_test" do + field :member_id, :integer + field :started_at, :utc_datetime_usec + field :ended_at, :utc_datetime_usec + end + end + @base_migration 2_000_000 setup_all do ExUnit.CaptureLog.capture_log(fn -> num = @base_migration + System.unique_integer([:positive]) up(PoolRepo, num, ConstraintMigration, log: false) + up(PoolRepo, num + 1, ProcedureEmulatingConstraintMigration, log: false) end) :ok @@ -46,10 +124,13 @@ defmodule Ecto.Integration.ConstraintsTest do test "check constraint" do # When the changeset doesn't expect the db error changeset = Ecto.Changeset.change(%Constraint{}, price: -10) + exception = - assert_raise Ecto.ConstraintError, ~r/constraint error when attempting to insert struct/, fn -> - PoolRepo.insert(changeset) - end + assert_raise Ecto.ConstraintError, + ~r/constraint error when attempting to insert struct/, + fn -> + PoolRepo.insert(changeset) + end assert exception.message =~ "\"positive_price\" (check_constraint)" assert exception.message =~ "The changeset has not defined any constraint." @@ -60,24 +141,111 @@ defmodule Ecto.Integration.ConstraintsTest do changeset |> Ecto.Changeset.check_constraint(:price, name: :positive_price) |> PoolRepo.insert() - assert changeset.errors == [price: {"is invalid", [constraint: :check, constraint_name: "positive_price"]}] + + assert changeset.errors == [ + price: {"is invalid", [constraint: :check, constraint_name: "positive_price"]} + ] + assert changeset.data.__meta__.state == :built # When the changeset does expect the db error and gives a custom message changeset = Ecto.Changeset.change(%Constraint{}, price: -10) + {:error, changeset} = changeset - |> Ecto.Changeset.check_constraint(:price, name: :positive_price, message: "price must be greater than 0") + |> Ecto.Changeset.check_constraint(:price, + name: :positive_price, + message: "price must be greater than 0" + ) |> PoolRepo.insert() - assert changeset.errors == [price: {"price must be greater than 0", [constraint: :check, constraint_name: "positive_price"]}] + + assert changeset.errors == [ + price: + {"price must be greater than 0", + [constraint: :check, constraint_name: "positive_price"]} + ] + assert changeset.data.__meta__.state == :built # When the change does not violate the check constraint changeset = Ecto.Changeset.change(%Constraint{}, price: 10, from: 100, to: 200) - {:ok, changeset} = + + {:ok, result} = changeset - |> Ecto.Changeset.check_constraint(:price, name: :positive_price, message: "price must be greater than 0") + |> Ecto.Changeset.check_constraint(:price, + name: :positive_price, + message: "price must be greater than 0" + ) + |> PoolRepo.insert() + + assert is_integer(result.id) + end + + test "custom handled constraint" do + changeset = Ecto.Changeset.change(%Constraint{}, from: 0, to: 10) + {:ok, item} = PoolRepo.insert(changeset) + + non_overlapping_changeset = Ecto.Changeset.change(%Constraint{}, from: 11, to: 12) + {:ok, _} = PoolRepo.insert(non_overlapping_changeset) + + overlapping_changeset = Ecto.Changeset.change(%Constraint{}, from: 9, to: 12) + + msg_re = ~r/constraint error when attempting to insert struct/ + + # When the changeset doesn't expect the db error + exception = + assert_raise Ecto.ConstraintError, msg_re, fn -> PoolRepo.insert(overlapping_changeset) end + + assert exception.message =~ "\"cannot_overlap\" (exclusion_constraint)" + assert exception.message =~ "The changeset has not defined any constraint." + assert exception.message =~ "call `exclusion_constraint/3`" + + ##### + + # When the changeset does expect the db error + # but the key does not match the default generated by `exclusion_constraint` + exception = + assert_raise Ecto.ConstraintError, msg_re, fn -> + overlapping_changeset + |> Ecto.Changeset.exclusion_constraint(:from) + |> PoolRepo.insert() + end + assert exception.message =~ "\"cannot_overlap\" (exclusion_constraint)" + + # When the changeset does expect the db error, but doesn't give a custom message + {:error, changeset} = + overlapping_changeset + |> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap) + |> PoolRepo.insert() + assert changeset.errors == [from: {"violates an exclusion constraint", [constraint: :exclusion, constraint_name: "cannot_overlap"]}] + assert changeset.data.__meta__.state == :built + + # When the changeset does expect the db error and gives a custom message + {:error, changeset} = + overlapping_changeset + |> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap, message: "must not overlap") + |> PoolRepo.insert() + assert changeset.errors == [from: {"must not overlap", [constraint: :exclusion, constraint_name: "cannot_overlap"]}] + assert changeset.data.__meta__.state == :built + + + # When the changeset does expect the db error, but a different handler is used + exception = + assert_raise MyXQL.Error, fn -> + overlapping_changeset + |> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap) + |> PoolRepo.insert(constraint_handler: Ecto.Adapters.MyXQL.Connection) + end + assert exception.message =~ "Overlapping values for key 'constraints_test.cannot_overlap'" + + # When custom error is coming from an UPDATE + overlapping_update_changeset = Ecto.Changeset.change(item, from: 0, to: 9) + + {:error, changeset} = + overlapping_update_changeset + |> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap, message: "must not overlap") |> PoolRepo.insert() - assert is_integer(changeset.id) + assert changeset.errors == [from: {"must not overlap", [constraint: :exclusion, constraint_name: "cannot_overlap"]}] + assert changeset.data.__meta__.state == :loaded end end diff --git a/integration_test/myxql/test_helper.exs b/integration_test/myxql/test_helper.exs index 29a0944e..51ec68c6 100644 --- a/integration_test/myxql/test_helper.exs +++ b/integration_test/myxql/test_helper.exs @@ -57,7 +57,9 @@ Application.put_env(:ecto_sql, PoolRepo, url: Application.get_env(:ecto_sql, :mysql_test_url) <> "/ecto_test", pool_size: 5, pool_count: String.to_integer(System.get_env("POOL_COUNT", "1")), - show_sensitive_data_on_connection_error: true + show_sensitive_data_on_connection_error: true, + # Passes through into adapter_meta + constraint_handler: Ecto.Integration.ConstraintsTest.CustomConstraintHandler ) defmodule Ecto.Integration.PoolRepo do @@ -84,6 +86,9 @@ _ = Ecto.Adapters.MyXQL.storage_down(TestRepo.config()) :ok = Ecto.Adapters.MyXQL.storage_up(TestRepo.config()) {:ok, _pid} = TestRepo.start_link() + +# Passes through into adapter_meta, overrides Application config +# {:ok, _pid} = PoolRepo.start_link([constraint_handler: Ecto.Integration.ConstraintsTest.CustomConstraintHandler]) {:ok, _pid} = PoolRepo.start_link() %{rows: [[version]]} = TestRepo.query!("SELECT @@version", []) diff --git a/lib/ecto/adapters/myxql.ex b/lib/ecto/adapters/myxql.ex index dc595d06..6749636f 100644 --- a/lib/ecto/adapters/myxql.ex +++ b/lib/ecto/adapters/myxql.ex @@ -341,7 +341,7 @@ defmodule Ecto.Adapters.MyXQL do {:ok, last_insert_id(key, last_insert_id)} {:error, err} -> - case @conn.to_constraints(err, source: source) do + case Ecto.Adapters.SQL.to_constraints(adapter_meta, opts, err, source: source) do [] -> raise err constraints -> {:invalid, constraints} end diff --git a/lib/ecto/adapters/sql.ex b/lib/ecto/adapters/sql.ex index 68604e47..7c60f41e 100644 --- a/lib/ecto/adapters/sql.ex +++ b/lib/ecto/adapters/sql.ex @@ -668,6 +668,12 @@ defmodule Ecto.Adapters.SQL do sql_call(adapter_meta, :query_many, [sql], params, opts) end + def to_constraints(adapter_meta, opts, err, err_opts) do + %{constraint_handler: constraint_handler} = adapter_meta + constraint_handler = Keyword.get(opts, :constraint_handler) || constraint_handler + constraint_handler.to_constraints(err, err_opts) + end + defp sql_call(adapter_meta, callback, args, params, opts) do %{ pid: pool, @@ -883,6 +889,7 @@ defmodule Ecto.Adapters.SQL do """ end + constraint_handler = Keyword.get(config, :constraint_handler, connection) stacktrace = Keyword.get(config, :stacktrace) telemetry_prefix = Keyword.fetch!(config, :telemetry_prefix) telemetry = {config[:repo], log, telemetry_prefix ++ [:query]} @@ -895,6 +902,7 @@ defmodule Ecto.Adapters.SQL do meta = %{ telemetry: telemetry, sql: connection, + constraint_handler: constraint_handler, stacktrace: stacktrace, log_stacktrace_mfa: log_stacktrace_mfa, opts: Keyword.take(config, @pool_opts) @@ -1156,7 +1164,7 @@ defmodule Ecto.Adapters.SQL do @doc false def struct( adapter_meta, - conn, + _conn, sql, operation, source, @@ -1191,7 +1199,7 @@ defmodule Ecto.Adapters.SQL do operation: operation {:error, err} -> - case conn.to_constraints(err, source: source) do + case to_constraints(adapter_meta, opts, err, source: source) do [] -> raise_sql_call_error(err) constraints -> {:invalid, constraints} end From f05f3aaa41aeb646209383b115e79d66c431c494 Mon Sep 17 00:00:00 2001 From: Peter Mueller <6015288+petermueller@users.noreply.github.com> Date: Sun, 28 Jul 2024 17:05:47 -0400 Subject: [PATCH 2/8] don't fail MySQL 5.x constraint tests --- integration_test/myxql/constraints_test.exs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/integration_test/myxql/constraints_test.exs b/integration_test/myxql/constraints_test.exs index 00cc0568..2df1c08b 100644 --- a/integration_test/myxql/constraints_test.exs +++ b/integration_test/myxql/constraints_test.exs @@ -27,7 +27,7 @@ defmodule Ecto.Integration.ConstraintsTest do defp strip_source(name, source), do: String.trim_leading(name, "#{source}.") end - defmodule ConstraintMigration do + defmodule ConstraintTableMigration do use Ecto.Migration @table table(:constraints_test) @@ -38,7 +38,15 @@ defmodule Ecto.Integration.ConstraintsTest do add :from, :integer add :to, :integer end + end + end + + defmodule ConstraintMigration do + use Ecto.Migration + @table table(:constraints_test) + + def change do # Only valid after MySQL 8.0.19 create constraint(@table.name, :positive_price, check: "price > 0") end @@ -113,7 +121,7 @@ defmodule Ecto.Integration.ConstraintsTest do setup_all do ExUnit.CaptureLog.capture_log(fn -> num = @base_migration + System.unique_integer([:positive]) - up(PoolRepo, num, ConstraintMigration, log: false) + up(PoolRepo, num, ConstraintTableMigration, log: false) up(PoolRepo, num + 1, ProcedureEmulatingConstraintMigration, log: false) end) @@ -122,6 +130,11 @@ defmodule Ecto.Integration.ConstraintsTest do @tag :create_constraint test "check constraint" do + num = @base_migration + System.unique_integer([:positive]) + ExUnit.CaptureLog.capture_log(fn -> + :ok = up(PoolRepo, num, ConstraintMigration, log: false) + end) + # When the changeset doesn't expect the db error changeset = Ecto.Changeset.change(%Constraint{}, price: -10) From 19d820f7b4d536ecfba5eb856afae5cf563f267c Mon Sep 17 00:00:00 2001 From: Peter Mueller <6015288+petermueller@users.noreply.github.com> Date: Sun, 28 Jul 2024 23:20:32 -0400 Subject: [PATCH 3/8] add `Ecto.Adapters.SQL.Constraint` --- lib/ecto/adapters/sql.ex | 1 + lib/ecto/adapters/sql/constraint.ex | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 lib/ecto/adapters/sql/constraint.ex diff --git a/lib/ecto/adapters/sql.ex b/lib/ecto/adapters/sql.ex index 7c60f41e..ddf4e8a3 100644 --- a/lib/ecto/adapters/sql.ex +++ b/lib/ecto/adapters/sql.ex @@ -668,6 +668,7 @@ defmodule Ecto.Adapters.SQL do sql_call(adapter_meta, :query_many, [sql], params, opts) end + @doc false def to_constraints(adapter_meta, opts, err, err_opts) do %{constraint_handler: constraint_handler} = adapter_meta constraint_handler = Keyword.get(opts, :constraint_handler) || constraint_handler diff --git a/lib/ecto/adapters/sql/constraint.ex b/lib/ecto/adapters/sql/constraint.ex new file mode 100644 index 00000000..6e52524b --- /dev/null +++ b/lib/ecto/adapters/sql/constraint.ex @@ -0,0 +1,19 @@ +defmodule Ecto.Adapters.SQL.Constraint do + @moduledoc """ + Specifies the constraint handling API + """ + + @doc """ + Receives the exception returned by `c:Ecto.Adapters.SQL.Connection.query/4`. + + The constraints are in the keyword list and must return the + constraint type, like `:unique`, and the constraint name as + a string, for example: + + [unique: "posts_title_index"] + + Must return an empty list if the error does not come + from any constraint. + """ + @callback to_constraints(exception :: Exception.t(), options :: Keyword.t()) :: Keyword.t() +end From d32d15e29d86c08e4689fc24ed43ca92f641a9db Mon Sep 17 00:00:00 2001 From: Peter Mueller <6015288+petermueller@users.noreply.github.com> Date: Mon, 29 Jul 2024 00:45:59 -0400 Subject: [PATCH 4/8] remove unused schema from constraint test --- integration_test/myxql/constraints_test.exs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/integration_test/myxql/constraints_test.exs b/integration_test/myxql/constraints_test.exs index 2df1c08b..0650a04a 100644 --- a/integration_test/myxql/constraints_test.exs +++ b/integration_test/myxql/constraints_test.exs @@ -106,16 +106,6 @@ defmodule Ecto.Integration.ConstraintsTest do end end - defmodule CustomConstraint do - use Ecto.Integration.Schema - - schema "procedure_constraints_test" do - field :member_id, :integer - field :started_at, :utc_datetime_usec - field :ended_at, :utc_datetime_usec - end - end - @base_migration 2_000_000 setup_all do @@ -213,8 +203,6 @@ defmodule Ecto.Integration.ConstraintsTest do assert exception.message =~ "The changeset has not defined any constraint." assert exception.message =~ "call `exclusion_constraint/3`" - ##### - # When the changeset does expect the db error # but the key does not match the default generated by `exclusion_constraint` exception = From 4b41c2eeabf91d6ec54256266769d98a0e8d8f43 Mon Sep 17 00:00:00 2001 From: Peter Mueller <6015288+petermueller@users.noreply.github.com> Date: Wed, 22 Oct 2025 01:49:27 -0400 Subject: [PATCH 5/8] swap to mfargs for `:constraint_handler` option - adds initial docs - updates behaviours and built-in connections --- integration_test/myxql/constraints_test.exs | 66 +++++++++++++---- integration_test/myxql/test_helper.exs | 4 +- lib/ecto/adapters/myxql.ex | 2 +- lib/ecto/adapters/myxql/connection.ex | 1 + lib/ecto/adapters/postgres/connection.ex | 1 + lib/ecto/adapters/sql.ex | 78 +++++++++++++++++++-- lib/ecto/adapters/sql/connection.ex | 14 ---- lib/ecto/adapters/sql/constraint.ex | 2 + lib/ecto/adapters/tds/connection.ex | 1 + 9 files changed, 131 insertions(+), 38 deletions(-) diff --git a/integration_test/myxql/constraints_test.exs b/integration_test/myxql/constraints_test.exs index 0650a04a..a45ab1b4 100644 --- a/integration_test/myxql/constraints_test.exs +++ b/integration_test/myxql/constraints_test.exs @@ -5,8 +5,11 @@ defmodule Ecto.Integration.ConstraintsTest do alias Ecto.Integration.PoolRepo defmodule CustomConstraintHandler do + @behaviour Ecto.Adapters.SQL.Constraint + @quotes ~w(" ' `) + @impl Ecto.Adapters.SQL.Constraint # An example of a custom handler a user might write def to_constraints(%MyXQL.Error{mysql: %{name: :ER_SIGNAL_EXCEPTION}, message: message}, opts) do # Assumes this is the only use-case of `ER_SIGNAL_EXCEPTION` the user has implemented custom errors for @@ -41,7 +44,7 @@ defmodule Ecto.Integration.ConstraintsTest do end end - defmodule ConstraintMigration do + defmodule CheckConstraintMigration do use Ecto.Migration @table table(:constraints_test) @@ -52,7 +55,7 @@ defmodule Ecto.Integration.ConstraintsTest do end end - defmodule ProcedureEmulatingConstraintMigration do + defmodule TriggerEmulatingConstraintMigration do use Ecto.Migration @table_name :constraints_test @@ -70,11 +73,14 @@ defmodule Ecto.Integration.ConstraintsTest do drop_triggers(@table_name) end + # FOR EACH ROW, not a great example performance-wise, + # but demonstrates the feature defp trigger_sql(table_name, before_type) do ~s""" CREATE TRIGGER #{table_name}_#{String.downcase(before_type)}_overlap BEFORE #{String.upcase(before_type)} - ON #{table_name} FOR EACH ROW + ON #{table_name} + FOR EACH ROW BEGIN DECLARE v_rowcount INT; DECLARE v_msg VARCHAR(200); @@ -112,7 +118,6 @@ defmodule Ecto.Integration.ConstraintsTest do ExUnit.CaptureLog.capture_log(fn -> num = @base_migration + System.unique_integer([:positive]) up(PoolRepo, num, ConstraintTableMigration, log: false) - up(PoolRepo, num + 1, ProcedureEmulatingConstraintMigration, log: false) end) :ok @@ -121,8 +126,9 @@ defmodule Ecto.Integration.ConstraintsTest do @tag :create_constraint test "check constraint" do num = @base_migration + System.unique_integer([:positive]) + ExUnit.CaptureLog.capture_log(fn -> - :ok = up(PoolRepo, num, ConstraintMigration, log: false) + :ok = up(PoolRepo, num, CheckConstraintMigration, log: false) end) # When the changeset doesn't expect the db error @@ -131,9 +137,7 @@ defmodule Ecto.Integration.ConstraintsTest do exception = assert_raise Ecto.ConstraintError, ~r/constraint error when attempting to insert struct/, - fn -> - PoolRepo.insert(changeset) - end + fn -> PoolRepo.insert(changeset) end assert exception.message =~ "\"positive_price\" (check_constraint)" assert exception.message =~ "The changeset has not defined any constraint." @@ -184,7 +188,14 @@ defmodule Ecto.Integration.ConstraintsTest do assert is_integer(result.id) end + @tag :constraint_handler test "custom handled constraint" do + num = @base_migration + System.unique_integer([:positive]) + + ExUnit.CaptureLog.capture_log(fn -> + :ok = up(PoolRepo, num, TriggerEmulatingConstraintMigration, log: false) + end) + changeset = Ecto.Changeset.change(%Constraint{}, from: 0, to: 10) {:ok, item} = PoolRepo.insert(changeset) @@ -211,6 +222,7 @@ defmodule Ecto.Integration.ConstraintsTest do |> Ecto.Changeset.exclusion_constraint(:from) |> PoolRepo.insert() end + assert exception.message =~ "\"cannot_overlap\" (exclusion_constraint)" # When the changeset does expect the db error, but doesn't give a custom message @@ -218,25 +230,41 @@ defmodule Ecto.Integration.ConstraintsTest do overlapping_changeset |> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap) |> PoolRepo.insert() - assert changeset.errors == [from: {"violates an exclusion constraint", [constraint: :exclusion, constraint_name: "cannot_overlap"]}] + + assert changeset.errors == [ + from: + {"violates an exclusion constraint", + [constraint: :exclusion, constraint_name: "cannot_overlap"]} + ] + assert changeset.data.__meta__.state == :built # When the changeset does expect the db error and gives a custom message {:error, changeset} = overlapping_changeset - |> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap, message: "must not overlap") + |> Ecto.Changeset.exclusion_constraint(:from, + name: :cannot_overlap, + message: "must not overlap" + ) |> PoolRepo.insert() - assert changeset.errors == [from: {"must not overlap", [constraint: :exclusion, constraint_name: "cannot_overlap"]}] - assert changeset.data.__meta__.state == :built + assert changeset.errors == [ + from: + {"must not overlap", [constraint: :exclusion, constraint_name: "cannot_overlap"]} + ] + + assert changeset.data.__meta__.state == :built # When the changeset does expect the db error, but a different handler is used exception = assert_raise MyXQL.Error, fn -> overlapping_changeset |> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap) - |> PoolRepo.insert(constraint_handler: Ecto.Adapters.MyXQL.Connection) + |> PoolRepo.insert( + constraint_handler: {Ecto.Adapters.MyXQL.Connection, :to_constraints, []} + ) end + assert exception.message =~ "Overlapping values for key 'constraints_test.cannot_overlap'" # When custom error is coming from an UPDATE @@ -244,9 +272,17 @@ defmodule Ecto.Integration.ConstraintsTest do {:error, changeset} = overlapping_update_changeset - |> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap, message: "must not overlap") + |> Ecto.Changeset.exclusion_constraint(:from, + name: :cannot_overlap, + message: "must not overlap" + ) |> PoolRepo.insert() - assert changeset.errors == [from: {"must not overlap", [constraint: :exclusion, constraint_name: "cannot_overlap"]}] + + assert changeset.errors == [ + from: + {"must not overlap", [constraint: :exclusion, constraint_name: "cannot_overlap"]} + ] + assert changeset.data.__meta__.state == :loaded end end diff --git a/integration_test/myxql/test_helper.exs b/integration_test/myxql/test_helper.exs index 51ec68c6..3db325ce 100644 --- a/integration_test/myxql/test_helper.exs +++ b/integration_test/myxql/test_helper.exs @@ -59,7 +59,7 @@ Application.put_env(:ecto_sql, PoolRepo, pool_count: String.to_integer(System.get_env("POOL_COUNT", "1")), show_sensitive_data_on_connection_error: true, # Passes through into adapter_meta - constraint_handler: Ecto.Integration.ConstraintsTest.CustomConstraintHandler + constraint_handler: {Ecto.Integration.ConstraintsTest.CustomConstraintHandler, :to_constraints, []} ) defmodule Ecto.Integration.PoolRepo do @@ -88,7 +88,7 @@ _ = Ecto.Adapters.MyXQL.storage_down(TestRepo.config()) {:ok, _pid} = TestRepo.start_link() # Passes through into adapter_meta, overrides Application config -# {:ok, _pid} = PoolRepo.start_link([constraint_handler: Ecto.Integration.ConstraintsTest.CustomConstraintHandler]) +# {:ok, _pid} = PoolRepo.start_link([constraint_handler: {Ecto.Integration.ConstraintsTest.CustomConstraintHandler, :to_constraints, []}]) {:ok, _pid} = PoolRepo.start_link() %{rows: [[version]]} = TestRepo.query!("SELECT @@version", []) diff --git a/lib/ecto/adapters/myxql.ex b/lib/ecto/adapters/myxql.ex index 6749636f..4e9acd6e 100644 --- a/lib/ecto/adapters/myxql.ex +++ b/lib/ecto/adapters/myxql.ex @@ -341,7 +341,7 @@ defmodule Ecto.Adapters.MyXQL do {:ok, last_insert_id(key, last_insert_id)} {:error, err} -> - case Ecto.Adapters.SQL.to_constraints(adapter_meta, opts, err, source: source) do + case Ecto.Adapters.SQL.to_constraints(adapter_meta, err, opts, source: source) do [] -> raise err constraints -> {:invalid, constraints} end diff --git a/lib/ecto/adapters/myxql/connection.ex b/lib/ecto/adapters/myxql/connection.ex index f5a36695..4bb98945 100644 --- a/lib/ecto/adapters/myxql/connection.ex +++ b/lib/ecto/adapters/myxql/connection.ex @@ -4,6 +4,7 @@ if Code.ensure_loaded?(MyXQL) do alias Ecto.Adapters.SQL @behaviour Ecto.Adapters.SQL.Connection + @behaviour Ecto.Adapters.SQL.Constraint ## Connection diff --git a/lib/ecto/adapters/postgres/connection.ex b/lib/ecto/adapters/postgres/connection.ex index deae2379..f1d6a590 100644 --- a/lib/ecto/adapters/postgres/connection.ex +++ b/lib/ecto/adapters/postgres/connection.ex @@ -4,6 +4,7 @@ if Code.ensure_loaded?(Postgrex) do @default_port 5432 @behaviour Ecto.Adapters.SQL.Connection + @behaviour Ecto.Adapters.SQL.Constraint @explain_prepared_statement_name "ecto_explain_statement" ## Module and Options diff --git a/lib/ecto/adapters/sql.ex b/lib/ecto/adapters/sql.ex index ddf4e8a3..e8b7e8cf 100644 --- a/lib/ecto/adapters/sql.ex +++ b/lib/ecto/adapters/sql.ex @@ -37,6 +37,9 @@ defmodule Ecto.Adapters.SQL do * `to_sql(type, query)` - shortcut for `Ecto.Adapters.SQL.to_sql/3` + * `to_constraints(exception, opts, error_opts)` - + shortcut for `Ecto.Adapters.SQL.to_constraints/4` + Generally speaking, you must invoke those functions directly from your repository, for example: `MyApp.Repo.query("SELECT true")`. You can also invoke them directly from `Ecto.Adapters.SQL`, but @@ -393,6 +396,45 @@ defmodule Ecto.Adapters.SQL do {"SELECT p.id, p.title, p.inserted_at, p.created_at FROM posts as p", []} """ + # TODO - add docs here and/or somewhere for how to pass `constraint_handler` per call + @to_constraints_doc """ + Handles adapter-specific exceptions, converting them to + the corresponding contraint errors. + + The constraints are in the keyword list and must return the + constraint type, like `:unique`, and the constraint name as + a string, for example: + + [unique: "posts_title_index"] + + Returning an empty list signifies the error does not come + from any constraint, and should continue with the default + exception handling path (i.e. raise or further handling). + + ## Options + * `:constraint_handler` - A module, function, and list of arguments (`mfargs`) + + The `:constraint_handler` option defaults to the adapter's connection module. + For the built-in adapters this would be: + + * `Ecto.Adapters.Postgres.Connection.to_constraints/2` + * `Ecto.Adapters.MyXQL.Connection.to_constraints/2` + * `Ecto.Adapters.Tds.Connection.to_constraints/2` + + See `Ecto.Adapters.SQL.Constraint` if you need to fully + customize the handling of constraints for all operations. + + ## Examples + + # Postgres + iex> MyRepo.to_constraints(%Postgrex.Error{code: :unique, constraint: "posts_title_index"}, []) + [unique: "posts_title_index"] + + # MySQL + iex> MyRepo.to_constraints(%MyXQL.Error{mysql: %{name: :ER_CHECK_CONSTRAINT_VIOLATED}, message: "Check constraint 'positive_price' is violated."}, []) + [check: "positive_price"] + """ + @explain_doc """ Executes an EXPLAIN statement or similar for the given query according to its kind and the adapter in the given repository. @@ -668,11 +710,22 @@ defmodule Ecto.Adapters.SQL do sql_call(adapter_meta, :query_many, [sql], params, opts) end - @doc false - def to_constraints(adapter_meta, opts, err, err_opts) do + @doc @to_constraints_doc + @spec to_constraints( + pid() | Ecto.Repo.t() | Ecto.Adapter.adapter_meta(), + exception :: Exception.t(), + options :: Keyword.t(), + error_options :: Keyword.t() + ) :: Keyword.t() + def to_constraints(repo, err, opts, err_opts) when is_atom(repo) or is_pid(repo) do + to_constraints(Ecto.Adapter.lookup_meta(repo), err, opts, err_opts) + end + + def to_constraints(adapter_meta, err, opts, err_opts) do %{constraint_handler: constraint_handler} = adapter_meta - constraint_handler = Keyword.get(opts, :constraint_handler) || constraint_handler - constraint_handler.to_constraints(err, err_opts) + {constraint_mod, fun, args} = Keyword.get(opts, :constraint_handler) || constraint_handler + args = [err, err_opts | args] + apply(constraint_mod, fun, args) end defp sql_call(adapter_meta, callback, args, params, opts) do @@ -794,6 +847,7 @@ defmodule Ecto.Adapters.SQL do query_many_doc = @query_many_doc query_many_bang_doc = @query_many_bang_doc to_sql_doc = @to_sql_doc + to_constraints_doc = @to_constraints_doc explain_doc = @explain_doc disconnect_all_doc = @disconnect_all_doc @@ -833,6 +887,16 @@ defmodule Ecto.Adapters.SQL do Ecto.Adapters.SQL.to_sql(operation, get_dynamic_repo(), queryable) end + @doc unquote(to_constraints_doc) + @spec to_constraints( + exception :: Exception.t(), + options :: Keyword.t(), + error_options :: Keyword.t() + ) :: Keyword.t() + def to_constraints(err, opts, err_opts) do + Ecto.Adapters.SQL.to_constraints(get_dynamic_repo(), err, opts, err_opts) + end + @doc unquote(explain_doc) @spec explain(:all | :update_all | :delete_all, Ecto.Queryable.t(), opts :: Keyword.t()) :: String.t() | Exception.t() | list(map) @@ -890,7 +954,9 @@ defmodule Ecto.Adapters.SQL do """ end - constraint_handler = Keyword.get(config, :constraint_handler, connection) + constraint_handler = + Keyword.get(config, :constraint_handler, {connection, :to_constraints, []}) + stacktrace = Keyword.get(config, :stacktrace) telemetry_prefix = Keyword.fetch!(config, :telemetry_prefix) telemetry = {config[:repo], log, telemetry_prefix ++ [:query]} @@ -1200,7 +1266,7 @@ defmodule Ecto.Adapters.SQL do operation: operation {:error, err} -> - case to_constraints(adapter_meta, opts, err, source: source) do + case to_constraints(adapter_meta, err, opts, source: source) do [] -> raise_sql_call_error(err) constraints -> {:invalid, constraints} end diff --git a/lib/ecto/adapters/sql/connection.ex b/lib/ecto/adapters/sql/connection.ex index 0b311754..82294cae 100644 --- a/lib/ecto/adapters/sql/connection.ex +++ b/lib/ecto/adapters/sql/connection.ex @@ -52,20 +52,6 @@ defmodule Ecto.Adapters.SQL.Connection do @callback stream(connection, statement, params, options :: Keyword.t()) :: Enum.t() - @doc """ - Receives the exception returned by `c:query/4`. - - The constraints are in the keyword list and must return the - constraint type, like `:unique`, and the constraint name as - a string, for example: - - [unique: "posts_title_index"] - - Must return an empty list if the error does not come - from any constraint. - """ - @callback to_constraints(exception :: Exception.t(), options :: Keyword.t()) :: Keyword.t() - ## Queries @doc """ diff --git a/lib/ecto/adapters/sql/constraint.ex b/lib/ecto/adapters/sql/constraint.ex index 6e52524b..b700b1a1 100644 --- a/lib/ecto/adapters/sql/constraint.ex +++ b/lib/ecto/adapters/sql/constraint.ex @@ -1,4 +1,6 @@ defmodule Ecto.Adapters.SQL.Constraint do + # TODO - add more docs around setting `:constraint_handler` globally + @moduledoc """ Specifies the constraint handling API """ diff --git a/lib/ecto/adapters/tds/connection.ex b/lib/ecto/adapters/tds/connection.ex index dc295f46..2a753fff 100644 --- a/lib/ecto/adapters/tds/connection.ex +++ b/lib/ecto/adapters/tds/connection.ex @@ -8,6 +8,7 @@ if Code.ensure_loaded?(Tds) do require Ecto.Schema @behaviour Ecto.Adapters.SQL.Connection + @behaviour Ecto.Adapters.SQL.Constraint @impl true def child_spec(opts) do From d9918f39b293bc33e235eb4fc032447be9e5b181 Mon Sep 17 00:00:00 2001 From: Peter Mueller <6015288+petermueller@users.noreply.github.com> Date: Wed, 22 Oct 2025 03:24:37 -0400 Subject: [PATCH 6/8] test postgres custom constraint handlers --- integration_test/pg/constraints_test.exs | 246 +++++++++++++++++++++-- integration_test/pg/test_helper.exs | 15 +- 2 files changed, 246 insertions(+), 15 deletions(-) diff --git a/integration_test/pg/constraints_test.exs b/integration_test/pg/constraints_test.exs index 9e8fe4e4..602ccf87 100644 --- a/integration_test/pg/constraints_test.exs +++ b/integration_test/pg/constraints_test.exs @@ -4,7 +4,27 @@ defmodule Ecto.Integration.ConstraintsTest do import Ecto.Migrator, only: [up: 4] alias Ecto.Integration.PoolRepo - defmodule ConstraintMigration do + defmodule CustomConstraintHandler do + @behaviour Ecto.Adapters.SQL.Constraint + + @impl Ecto.Adapters.SQL.Constraint + # An example of a custom handler a user might write + def to_constraints( + %Postgrex.Error{postgres: %{pg_code: "ZZ001", constraint: constraint}} = _err, + _opts + ) do + # Assumes that all pg_codes of ZZ001 are check constraint, + # which may or may not be realistic + [check: constraint] + end + + def to_constraints(err, opts) do + # Falls back to default `ecto_sql` handler for all others + Ecto.Adapters.Postgres.Connection.to_constraints(err, opts) + end + end + + defmodule ConstraintTableMigration do use Ecto.Migration @table table(:constraints_test) @@ -15,11 +35,81 @@ defmodule Ecto.Integration.ConstraintsTest do add :from, :integer add :to, :integer end - create constraint(@table.name, :cannot_overlap, exclude: ~s|gist (int4range("from", "to", '[]') WITH &&)|) + end + end + + defmodule CheckConstraintMigration do + use Ecto.Migration + + @table table(:constraints_test) + + def change do create constraint(@table.name, "positive_price", check: "price > 0") end end + defmodule ExclusionConstraintMigration do + use Ecto.Migration + + @table table(:constraints_test) + + def change do + create constraint(@table.name, :cannot_overlap, + exclude: ~s|gist (int4range("from", "to", '[]') WITH &&)| + ) + end + end + + defmodule TriggerEmulatingConstraintMigration do + use Ecto.Migration + + @table_name :constraints_test + + def up do + function_sql = ~s""" + CREATE OR REPLACE FUNCTION check_price_limit() + RETURNS TRIGGER AS $$ + BEGIN + IF NEW.price + 1 > 100 THEN + RAISE EXCEPTION SQLSTATE 'ZZ001' + USING MESSAGE = 'price must be less than 100, got ' || NEW.price::TEXT, + CONSTRAINT = 'price_above_max'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + """ + + insert_trigger_sql = trigger_sql(@table_name, "INSERT") + update_trigger_sql = trigger_sql(@table_name, "UPDATE") + + drop_triggers(@table_name) + repo().query!(function_sql) + repo().query!(insert_trigger_sql) + repo().query!(update_trigger_sql) + end + + def down do + drop_triggers(@table_name) + end + + # not a great example, but demonstrates the feature + defp trigger_sql(table_name, before_type) do + ~s""" + CREATE TRIGGER #{table_name}_before_price_#{String.downcase(before_type)} + BEFORE #{String.upcase(before_type)} + ON #{table_name} + FOR EACH ROW + EXECUTE FUNCTION check_price_limit(); + """ + end + + defp drop_triggers(table_name) do + repo().query!("DROP TRIGGER IF EXISTS #{table_name}_before_price_insert ON #{table_name}") + repo().query!("DROP TRIGGER IF EXISTS #{table_name}_before_price_update ON #{table_name}") + end + end + defmodule Constraint do use Ecto.Integration.Schema @@ -35,13 +125,19 @@ defmodule Ecto.Integration.ConstraintsTest do setup_all do ExUnit.CaptureLog.capture_log(fn -> num = @base_migration + System.unique_integer([:positive]) - up(PoolRepo, num, ConstraintMigration, log: false) + up(PoolRepo, num, ConstraintTableMigration, log: false) end) :ok end test "exclusion constraint" do + num = @base_migration + System.unique_integer([:positive]) + + ExUnit.CaptureLog.capture_log(fn -> + :ok = up(PoolRepo, num, ExclusionConstraintMigration, log: false) + end) + changeset = Ecto.Changeset.change(%Constraint{}, from: 0, to: 10) {:ok, _} = PoolRepo.insert(changeset) @@ -51,37 +147,55 @@ defmodule Ecto.Integration.ConstraintsTest do overlapping_changeset = Ecto.Changeset.change(%Constraint{}, from: 9, to: 12) exception = - assert_raise Ecto.ConstraintError, ~r/constraint error when attempting to insert struct/, fn -> - PoolRepo.insert(overlapping_changeset) - end + assert_raise Ecto.ConstraintError, + ~r/constraint error when attempting to insert struct/, + fn -> + PoolRepo.insert(overlapping_changeset) + end + assert exception.message =~ "\"cannot_overlap\" (exclusion_constraint)" assert exception.message =~ "The changeset has not defined any constraint." assert exception.message =~ "call `exclusion_constraint/3`" message = ~r/constraint error when attempting to insert struct/ + exception = assert_raise Ecto.ConstraintError, message, fn -> overlapping_changeset |> Ecto.Changeset.exclusion_constraint(:from) |> PoolRepo.insert() end + assert exception.message =~ "\"cannot_overlap\" (exclusion_constraint)" {:error, changeset} = overlapping_changeset |> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap) |> PoolRepo.insert() - assert changeset.errors == [from: {"violates an exclusion constraint", [constraint: :exclusion, constraint_name: "cannot_overlap"]}] + + assert changeset.errors == [ + from: + {"violates an exclusion constraint", + [constraint: :exclusion, constraint_name: "cannot_overlap"]} + ] + assert changeset.data.__meta__.state == :built end test "check constraint" do + num = @base_migration + System.unique_integer([:positive]) + + ExUnit.CaptureLog.capture_log(fn -> + :ok = up(PoolRepo, num, CheckConstraintMigration, log: false) + end) + # When the changeset doesn't expect the db error changeset = Ecto.Changeset.change(%Constraint{}, price: -10) + exception = - assert_raise Ecto.ConstraintError, ~r/constraint error when attempting to insert struct/, fn -> - PoolRepo.insert(changeset) - end + assert_raise Ecto.ConstraintError, + ~r/constraint error when attempting to insert struct/, + fn -> PoolRepo.insert(changeset) end assert exception.message =~ "\"positive_price\" (check_constraint)" assert exception.message =~ "The changeset has not defined any constraint." @@ -92,24 +206,128 @@ defmodule Ecto.Integration.ConstraintsTest do changeset |> Ecto.Changeset.check_constraint(:price, name: :positive_price) |> PoolRepo.insert() - assert changeset.errors == [price: {"is invalid", [constraint: :check, constraint_name: "positive_price"]}] + + assert changeset.errors == [ + price: {"is invalid", [constraint: :check, constraint_name: "positive_price"]} + ] + assert changeset.data.__meta__.state == :built # When the changeset does expect the db error and gives a custom message changeset = Ecto.Changeset.change(%Constraint{}, price: -10) + {:error, changeset} = changeset - |> Ecto.Changeset.check_constraint(:price, name: :positive_price, message: "price must be greater than 0") + |> Ecto.Changeset.check_constraint(:price, + name: :positive_price, + message: "price must be greater than 0" + ) |> PoolRepo.insert() - assert changeset.errors == [price: {"price must be greater than 0", [constraint: :check, constraint_name: "positive_price"]}] + + assert changeset.errors == [ + price: + {"price must be greater than 0", + [constraint: :check, constraint_name: "positive_price"]} + ] + assert changeset.data.__meta__.state == :built # When the change does not violate the check constraint changeset = Ecto.Changeset.change(%Constraint{}, price: 10, from: 100, to: 200) + {:ok, changeset} = changeset - |> Ecto.Changeset.check_constraint(:price, name: :positive_price, message: "price must be greater than 0") + |> Ecto.Changeset.check_constraint(:price, + name: :positive_price, + message: "price must be greater than 0" + ) |> PoolRepo.insert() + assert is_integer(changeset.id) end + + @tag :constraint_handler + test "custom handled constraint" do + num = @base_migration + System.unique_integer([:positive]) + + ExUnit.CaptureLog.capture_log(fn -> + :ok = up(PoolRepo, num, TriggerEmulatingConstraintMigration, log: false) + end) + + changeset = Ecto.Changeset.change(%Constraint{}, price: 99, from: 201, to: 202) + {:ok, item} = PoolRepo.insert(changeset) + + above_max_changeset = Ecto.Changeset.change(%Constraint{}, price: 100) + + msg_re = ~r/constraint error when attempting to insert struct/ + + # When the changeset doesn't expect the db error + exception = + assert_raise Ecto.ConstraintError, msg_re, fn -> PoolRepo.insert(above_max_changeset) end + + assert exception.message =~ "\"price_above_max\" (check_constraint)" + assert exception.message =~ "The changeset has not defined any constraint." + assert exception.message =~ "call `check_constraint/3`" + + # When the changeset does expect the db error, but doesn't give a custom message + {:error, changeset} = + above_max_changeset + |> Ecto.Changeset.check_constraint(:price, name: :price_above_max) + |> PoolRepo.insert() + + assert changeset.errors == [ + price: {"is invalid", [constraint: :check, constraint_name: "price_above_max"]} + ] + + assert changeset.data.__meta__.state == :built + + # When the changeset does expect the db error and gives a custom message + {:error, changeset} = + above_max_changeset + |> Ecto.Changeset.check_constraint(:price, + name: :price_above_max, + message: "must be less than the max price" + ) + |> PoolRepo.insert() + + assert changeset.errors == [ + price: + {"must be less than the max price", + [constraint: :check, constraint_name: "price_above_max"]} + ] + + assert changeset.data.__meta__.state == :built + + # When the changeset does expect the db error, but a different handler is used + exception = + assert_raise Postgrex.Error, fn -> + above_max_changeset + |> Ecto.Changeset.check_constraint(:price, name: :price_above_max) + |> PoolRepo.insert( + constraint_handler: {Ecto.Adapters.Postgres.Connection, :to_constraints, []} + ) + end + + # Just raises as-is + assert exception.postgres.message == "price must be less than 100, got 100" + + # When custom error is coming from an UPDATE + above_max_update_changeset = Ecto.Changeset.change(item, price: 100) + + {:error, changeset} = + above_max_update_changeset + |> Ecto.Changeset.check_constraint(:price, + name: :price_above_max, + message: "must be less than the max price" + ) + |> PoolRepo.insert() + + assert changeset.errors == [ + price: + {"must be less than the max price", + [constraint: :check, constraint_name: "price_above_max"]} + ] + + assert changeset.data.__meta__.state == :loaded + end end diff --git a/integration_test/pg/test_helper.exs b/integration_test/pg/test_helper.exs index e61c7b50..696c216b 100644 --- a/integration_test/pg/test_helper.exs +++ b/integration_test/pg/test_helper.exs @@ -67,7 +67,16 @@ pool_repo_config = [ max_seconds: 10 ] -Application.put_env(:ecto_sql, PoolRepo, pool_repo_config) +Application.put_env( + :ecto_sql, + PoolRepo, + pool_repo_config ++ + [ + # Passes through into adapter_meta + constraint_handler: + {Ecto.Integration.ConstraintsTest.CustomConstraintHandler, :to_constraints, []} + ] +) Application.put_env( :ecto_sql, @@ -99,7 +108,11 @@ _ = Ecto.Adapters.Postgres.storage_down(TestRepo.config()) :ok = Ecto.Adapters.Postgres.storage_up(TestRepo.config()) {:ok, _pid} = TestRepo.start_link() + +# Passes through into adapter_meta, overrides Application config +# {:ok, _pid} = PoolRepo.start_link([constraint_handler: {Ecto.Integration.ConstraintsTest.CustomConstraintHandler, :to_constraints, []}]) {:ok, _pid} = PoolRepo.start_link() + {:ok, _pid} = AdvisoryLockPoolRepo.start_link() %{rows: [[version]]} = TestRepo.query!("SHOW server_version", []) From 2931996039e8d14f5e4e1204bdd2e973099a252b Mon Sep 17 00:00:00 2001 From: Peter Mueller <6015288+petermueller@users.noreply.github.com> Date: Wed, 22 Oct 2025 11:20:41 -0400 Subject: [PATCH 7/8] fix pg 9.5 constraint tests --- integration_test/pg/constraints_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration_test/pg/constraints_test.exs b/integration_test/pg/constraints_test.exs index 602ccf87..eba231ad 100644 --- a/integration_test/pg/constraints_test.exs +++ b/integration_test/pg/constraints_test.exs @@ -100,7 +100,7 @@ defmodule Ecto.Integration.ConstraintsTest do BEFORE #{String.upcase(before_type)} ON #{table_name} FOR EACH ROW - EXECUTE FUNCTION check_price_limit(); + EXECUTE PROCEDURE check_price_limit(); """ end From c17e146b66ec1d5bdf30b3318dbd3fa00b18ce51 Mon Sep 17 00:00:00 2001 From: Peter Mueller <6015288+petermueller@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:20:25 -0400 Subject: [PATCH 8/8] WIP - test tds custom constraint handlers --- integration_test/tds/constraints_test.exs | 301 +++++++++++++++++++++- integration_test/tds/test_helper.exs | 4 +- 2 files changed, 292 insertions(+), 13 deletions(-) diff --git a/integration_test/tds/constraints_test.exs b/integration_test/tds/constraints_test.exs index c66303e6..01700f32 100644 --- a/integration_test/tds/constraints_test.exs +++ b/integration_test/tds/constraints_test.exs @@ -4,7 +4,32 @@ defmodule Ecto.Integration.ConstraintsTest do import Ecto.Migrator, only: [up: 4] alias Ecto.Integration.PoolRepo - defmodule ConstraintMigration do + defmodule CustomConstraintHandler do + @behaviour Ecto.Adapters.SQL.Constraint + + @impl Ecto.Adapters.SQL.Constraint + # An example of a custom handler a user might write + def to_constraints(%Tds.Error{mssql: %{number: 50000, msg_text: message}}, opts) do + # Assumes this is the only use-case of error 50000 the user has implemented custom errors for + # Message format: "Overlapping values for key 'cannot_overlap'" + with [_, quoted] <- :binary.split(message, "Overlapping values for key "), + [_, index | _] <- :binary.split(quoted, ["'"]) do + [exclusion: strip_source(index, opts[:source])] + else + _ -> [] + end + end + + def to_constraints(err, opts) do + # Falls back to default `ecto_sql` handler for all others + Ecto.Adapters.Tds.Connection.to_constraints(err, opts) + end + + defp strip_source(name, nil), do: name + defp strip_source(name, source), do: String.trim_leading(name, "#{source}.") + end + + defmodule ConstraintTableMigration do use Ecto.Migration @table table(:constraints_test) @@ -15,7 +40,120 @@ defmodule Ecto.Integration.ConstraintsTest do add :from, :integer add :to, :integer end - create constraint(@table.name, :cannot_overlap, check: "[from] < [to]") + end + end + + defmodule CheckConstraintMigration do + use Ecto.Migration + + @table table(:constraints_test) + + def change do + create constraint(@table.name, :positive_price, check: "[price] > 0") + end + end + + defmodule TriggerEmulatingConstraintMigration do + use Ecto.Migration + + @table_name :constraints_test + + def up do + insert_trigger_sql = trigger_sql(@table_name, "INSERT") + update_trigger_sql = trigger_sql(@table_name, "UPDATE") + + drop_triggers(@table_name) + repo().query!(insert_trigger_sql) + repo().query!(update_trigger_sql) + end + + def down do + drop_triggers(@table_name) + end + + # Set-based INSTEAD OF trigger for MSSQL (handles multiple rows) + # Uses INSTEAD OF to work with Ecto's OUTPUT clause (AFTER triggers conflict with OUTPUT) + defp trigger_sql(table_name, before_type) do + ~s""" + CREATE TRIGGER #{table_name}_#{String.downcase(before_type)}_overlap + ON #{table_name} + INSTEAD OF #{String.upcase(before_type)} + AS + BEGIN + DECLARE @v_rowcount INT; + + DECLARE @OutputTable TABLE ( + ID INT, + price INT, + [from] INT, + [to] INT + ); + + -- Check for overlaps between inserted rows and existing rows + IF '#{before_type}' = 'INSERT' + BEGIN + -- For INSERT: check against existing rows + SELECT @v_rowcount = COUNT(*) + FROM inserted i + INNER JOIN #{table_name} t + ON (i.[from] <= t.[to] AND i.[to] >= t.[from]); + END + ELSE + BEGIN + -- For UPDATE: check against existing rows except the one being updated + SELECT @v_rowcount = COUNT(*) + FROM inserted i + INNER JOIN #{table_name} t + ON (i.[from] <= t.[to] AND i.[to] >= t.[from]) + AND t.id NOT IN (SELECT id FROM deleted); + END + + -- Also check for overlaps within the inserted set itself + IF @v_rowcount = 0 + BEGIN + SELECT @v_rowcount = COUNT(*) + FROM inserted i1 + INNER JOIN inserted i2 + ON (i1.[from] <= i2.[to] AND i1.[to] >= i2.[from]) + AND i1.id != i2.id; + END + + IF @v_rowcount > 0 + BEGIN + DECLARE @v_msg NVARCHAR(200); + SET @v_msg = 'Overlapping values for key ''#{table_name}.cannot_overlap'''; + THROW 50000, @v_msg, 1; + RETURN; + END + + IF '#{before_type}' = 'INSERT' + BEGIN + INSERT INTO #{table_name} (ID, price, [from], [to]) + OUTPUT INSERTED.ID, INSERTED.price, INSERTED.[from], INSERTED.[to] INTO @OutputTable (ID, price, [from], [to]) + SELECT i3.ID, i3.price, i3.[from], i3.[to] FROM inserted i3; + SELECT * FROM @OutputTable; + END + ELSE + BEGIN + UPDATE t2 + SET t2.price = i4.price, + t2.[from] = i4.[from], + t2.[to] = i4.[to] + FROM #{table_name} t2 + INNER JOIN inserted i4 ON t2.id = i4.id; + END + END; + """ + end + + defp drop_triggers(table_name) do + repo().query!( + "IF OBJECT_ID('#{table_name}_insert_overlap', 'TR') IS NOT NULL DROP TRIGGER #{table_name}_insert_overlap" + ) + + repo().query!( + "IF OBJECT_ID('#{table_name}_update_overlap', 'TR') IS NOT NULL DROP TRIGGER #{table_name}_update_overlap" + ) end end @@ -34,34 +172,173 @@ defmodule Ecto.Integration.ConstraintsTest do setup_all do ExUnit.CaptureLog.capture_log(fn -> num = @base_migration + System.unique_integer([:positive]) - up(PoolRepo, num, ConstraintMigration, log: false) + up(PoolRepo, num, ConstraintTableMigration, log: false) end) :ok end + @tag :create_constraint test "check constraint" do + num = @base_migration + System.unique_integer([:positive]) + + ExUnit.CaptureLog.capture_log(fn -> + :ok = up(PoolRepo, num, CheckConstraintMigration, log: false) + end) + + # When the changeset doesn't expect the db error + changeset = Ecto.Changeset.change(%Constraint{}, price: -10) + + exception = + assert_raise Ecto.ConstraintError, + ~r/constraint error when attempting to insert struct/, + fn -> PoolRepo.insert(changeset) end + + assert exception.message =~ "\"positive_price\" (check_constraint)" + assert exception.message =~ "The changeset has not defined any constraint." + assert exception.message =~ "call `check_constraint/3`" + + # When the changeset does expect the db error, but doesn't give a custom message + {:error, changeset} = + changeset + |> Ecto.Changeset.check_constraint(:price, name: :positive_price) + |> PoolRepo.insert() + + assert changeset.errors == [ + price: {"is invalid", [constraint: :check, constraint_name: "positive_price"]} + ] + + assert changeset.data.__meta__.state == :built + + # When the changeset does expect the db error and gives a custom message + changeset = Ecto.Changeset.change(%Constraint{}, price: -10) + + {:error, changeset} = + changeset + |> Ecto.Changeset.check_constraint(:price, + name: :positive_price, + message: "price must be greater than 0" + ) + |> PoolRepo.insert() + + assert changeset.errors == [ + price: + {"price must be greater than 0", + [constraint: :check, constraint_name: "positive_price"]} + ] + + assert changeset.data.__meta__.state == :built + + # When the change does not violate the check constraint + changeset = Ecto.Changeset.change(%Constraint{}, price: 10, from: 100, to: 200) + + {:ok, result} = + changeset + |> Ecto.Changeset.check_constraint(:price, + name: :positive_price, + message: "price must be greater than 0" + ) + |> PoolRepo.insert() + + assert is_integer(result.id) + end + + @tag :constraint_handler + test "custom handled constraint" do + num = @base_migration + System.unique_integer([:positive]) + + ExUnit.CaptureLog.capture_log(fn -> + :ok = up(PoolRepo, num, TriggerEmulatingConstraintMigration, log: false) + end) + changeset = Ecto.Changeset.change(%Constraint{}, from: 0, to: 10) - {:ok, _} = PoolRepo.insert(changeset) + + {:ok, item} = PoolRepo.insert(changeset, returning: false) non_overlapping_changeset = Ecto.Changeset.change(%Constraint{}, from: 11, to: 12) {:ok, _} = PoolRepo.insert(non_overlapping_changeset) - overlapping_changeset = Ecto.Changeset.change(%Constraint{}, from: 1900, to: 12) + overlapping_changeset = Ecto.Changeset.change(%Constraint{}, from: 9, to: 12) + msg_re = ~r/constraint error when attempting to insert struct/ + + # When the changeset doesn't expect the db error exception = - assert_raise Ecto.ConstraintError, ~r/constraint error when attempting to insert struct/, fn -> - PoolRepo.insert(overlapping_changeset) - end - assert exception.message =~ "\"cannot_overlap\" (check_constraint)" + assert_raise Ecto.ConstraintError, msg_re, fn -> PoolRepo.insert(overlapping_changeset) end + + assert exception.message =~ "\"cannot_overlap\" (exclusion_constraint)" assert exception.message =~ "The changeset has not defined any constraint." - assert exception.message =~ "call `check_constraint/3`" + assert exception.message =~ "call `exclusion_constraint/3`" + + # When the changeset does expect the db error + # but the key does not match the default generated by `exclusion_constraint` + exception = + assert_raise Ecto.ConstraintError, msg_re, fn -> + overlapping_changeset + |> Ecto.Changeset.exclusion_constraint(:from) + |> PoolRepo.insert() + end + + assert exception.message =~ "\"cannot_overlap\" (exclusion_constraint)" + # When the changeset does expect the db error, but doesn't give a custom message {:error, changeset} = overlapping_changeset - |> Ecto.Changeset.check_constraint(:from, name: :cannot_overlap) + |> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap) |> PoolRepo.insert() - assert changeset.errors == [from: {"is invalid", [constraint: :check, constraint_name: "cannot_overlap"]}] + + assert changeset.errors == [ + from: + {"violates an exclusion constraint", + [constraint: :exclusion, constraint_name: "cannot_overlap"]} + ] + assert changeset.data.__meta__.state == :built + + # When the changeset does expect the db error and gives a custom message + {:error, changeset} = + overlapping_changeset + |> Ecto.Changeset.exclusion_constraint(:from, + name: :cannot_overlap, + message: "must not overlap" + ) + |> PoolRepo.insert() + + assert changeset.errors == [ + from: + {"must not overlap", [constraint: :exclusion, constraint_name: "cannot_overlap"]} + ] + + assert changeset.data.__meta__.state == :built + + # When the changeset does expect the db error, but a different handler is used + exception = + assert_raise Tds.Error, fn -> + overlapping_changeset + |> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap) + |> PoolRepo.insert( + constraint_handler: {Ecto.Adapters.Tds.Connection, :to_constraints, []} + ) + end + + assert exception.message =~ "Overlapping values for key 'constraints_test.cannot_overlap'" + + # When custom error is coming from an UPDATE + overlapping_update_changeset = Ecto.Changeset.change(item, from: 0, to: 9) + + {:error, changeset} = + overlapping_update_changeset + |> Ecto.Changeset.exclusion_constraint(:from, + name: :cannot_overlap, + message: "must not overlap" + ) + |> PoolRepo.update() + + assert changeset.errors == [ + from: + {"must not overlap", [constraint: :exclusion, constraint_name: "cannot_overlap"]} + ] + + assert changeset.data.__meta__.state == :loaded end end diff --git a/integration_test/tds/test_helper.exs b/integration_test/tds/test_helper.exs index 3f0c2ce3..436db0f7 100644 --- a/integration_test/tds/test_helper.exs +++ b/integration_test/tds/test_helper.exs @@ -126,7 +126,9 @@ Application.put_env( PoolRepo, url: "#{Application.get_env(:ecto_sql, :tds_test_url)}/ecto_test", pool_size: 10, - set_allow_snapshot_isolation: :on + set_allow_snapshot_isolation: :on, + # Passes through into adapter_meta + constraint_handler: {Ecto.Integration.ConstraintsTest.CustomConstraintHandler, :to_constraints, []} ) defmodule Ecto.Integration.PoolRepo do