From c882d4827c16264acba8aebb1c4b6efe89af5bbe 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 01/10] 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 2323823f7..00cc05689 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 29a0944eb..51ec68c6a 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 6026cb260..13cea4e42 100644 --- a/lib/ecto/adapters/myxql.ex +++ b/lib/ecto/adapters/myxql.ex @@ -399,7 +399,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 f7a557c61..39e9d3f96 100644 --- a/lib/ecto/adapters/sql.ex +++ b/lib/ecto/adapters/sql.ex @@ -673,6 +673,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, @@ -888,6 +894,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]} @@ -900,6 +907,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) @@ -1161,7 +1169,7 @@ defmodule Ecto.Adapters.SQL do @doc false def struct( adapter_meta, - conn, + _conn, sql, operation, source, @@ -1196,7 +1204,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 368619c8d84e44b7088e340cae539675075858c8 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 02/10] 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 00cc05689..2df1c08b5 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 6dba4b7fccafe98f3665c8c90a37463bd2debc94 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 03/10] 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 39e9d3f96..46b55c37b 100644 --- a/lib/ecto/adapters/sql.ex +++ b/lib/ecto/adapters/sql.ex @@ -673,6 +673,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 000000000..6e52524bb --- /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 1e7dc5a366c33bc695633efa236a0ecec4d023b1 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 04/10] 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 2df1c08b5..0650a04aa 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 18937653d63c15c2d3369cb5e9dcb18893a5f51c 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 05/10] 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 0650a04aa..a45ab1b44 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 51ec68c6a..3db325ce7 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 13cea4e42..2dda844d0 100644 --- a/lib/ecto/adapters/myxql.ex +++ b/lib/ecto/adapters/myxql.ex @@ -399,7 +399,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 c560d3b2f..79fc2fab6 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 f5d3faf60..079bf0f53 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 46b55c37b..3da058da3 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")`. @@ -398,6 +401,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. @@ -673,11 +715,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 @@ -799,6 +852,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 @@ -838,6 +892,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) @@ -895,7 +959,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]} @@ -1205,7 +1271,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 19eee1587..05be0fc9f 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 6e52524bb..b700b1a15 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 be1cf229b..c47908b90 100644 --- a/lib/ecto/adapters/tds/connection.ex +++ b/lib/ecto/adapters/tds/connection.ex @@ -6,6 +6,7 @@ if Code.ensure_loaded?(Tds) do alias Ecto.Adapters.SQL @behaviour Ecto.Adapters.SQL.Connection + @behaviour Ecto.Adapters.SQL.Constraint @impl true def child_spec(opts) do From 7b9a19f4b6259ecbf49b59c49c64899aa8db101c 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 06/10] 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 9e8fe4e44..602ccf875 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 e61c7b505..696c216be 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 0f07e0bf320eef9b97de81d4ca63524854253151 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 07/10] 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 602ccf875..eba231ad6 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 0f6db45b1524365d272766dfb8cc973b4fa88a70 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 08/10] 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 c66303e62..01700f325 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 3f0c2ce36..436db0f7f 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 From e8ac2da4f2127f3c92a7f1e996990bfb6d2affc4 Mon Sep 17 00:00:00 2001 From: Peter Mueller <6015288+petermueller@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:11:43 -0400 Subject: [PATCH 09/10] change `:constraint_handler` to accept a function `:constraint_handler` now accepts a `(exception, opts -> keyword)` function instead of an MFA tuple. It can be passed per-operation or set globally via `default_options`/`prepare_options` in the repo. Removes `Ecto.Adapters.SQL.Constraint` behaviour and the config-level `:constraint_handler` option. The `to_constraints/2` callback stays on `Ecto.Adapters.SQL.Connection`. Adds `Ecto.Adapters.SQL.to_constraints/4` and a corresponding `MyRepo.to_constraints/3` convenience function. --- integration_test/myxql/constraints_test.exs | 82 ++----- integration_test/myxql/test_helper.exs | 6 +- integration_test/pg/constraints_test.exs | 77 ++----- integration_test/pg/test_helper.exs | 13 +- integration_test/tds/constraints_test.exs | 230 +------------------- integration_test/tds/test_helper.exs | 4 +- lib/ecto/adapters/myxql/connection.ex | 1 - lib/ecto/adapters/postgres/connection.ex | 1 - lib/ecto/adapters/sql.ex | 45 ++-- lib/ecto/adapters/sql/connection.ex | 14 ++ lib/ecto/adapters/sql/constraint.ex | 21 -- lib/ecto/adapters/tds/connection.ex | 1 - test/ecto/adapters/sql_test.exs | 62 ++++++ 13 files changed, 142 insertions(+), 415 deletions(-) delete mode 100644 lib/ecto/adapters/sql/constraint.ex create mode 100644 test/ecto/adapters/sql_test.exs diff --git a/integration_test/myxql/constraints_test.exs b/integration_test/myxql/constraints_test.exs index a45ab1b44..eecadffd4 100644 --- a/integration_test/myxql/constraints_test.exs +++ b/integration_test/myxql/constraints_test.exs @@ -5,12 +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 + # An example of a custom handler a user might write. + # Handles custom MySQL signal exceptions from triggers, + # falling back to the default handler. 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 "), @@ -196,6 +195,8 @@ defmodule Ecto.Integration.ConstraintsTest do :ok = up(PoolRepo, num, TriggerEmulatingConstraintMigration, log: false) end) + constraint_handler = &CustomConstraintHandler.to_constraints/2 + changeset = Ecto.Changeset.change(%Constraint{}, from: 0, to: 10) {:ok, item} = PoolRepo.insert(changeset) @@ -204,32 +205,11 @@ defmodule Ecto.Integration.ConstraintsTest do 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 + # Custom handler converts the trigger error into a constraint {:error, changeset} = overlapping_changeset |> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap) - |> PoolRepo.insert() + |> PoolRepo.insert(constraint_handler: constraint_handler) assert changeset.errors == [ from: @@ -239,50 +219,26 @@ defmodule Ecto.Integration.ConstraintsTest do assert changeset.data.__meta__.state == :built - # When the changeset does expect the db error and gives a custom message - {:error, changeset} = + # Without the custom handler, the default handler doesn't recognize + # the custom signal, so the error is raised as-is + assert_raise MyXQL.Error, fn -> overlapping_changeset - |> Ecto.Changeset.exclusion_constraint(:from, - name: :cannot_overlap, - message: "must not overlap" - ) + |> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap) |> PoolRepo.insert() + end - 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, :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) - + # Custom handler also works on UPDATE {:error, changeset} = - overlapping_update_changeset - |> Ecto.Changeset.exclusion_constraint(:from, - name: :cannot_overlap, - message: "must not overlap" - ) - |> PoolRepo.insert() + Ecto.Changeset.change(item, from: 0, to: 9) + |> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap) + |> PoolRepo.update(constraint_handler: constraint_handler) assert changeset.errors == [ from: - {"must not overlap", [constraint: :exclusion, constraint_name: "cannot_overlap"]} + {"violates an exclusion constraint", + [constraint: :exclusion, constraint_name: "cannot_overlap"]} ] assert changeset.data.__meta__.state == :loaded end -end +end \ No newline at end of file diff --git a/integration_test/myxql/test_helper.exs b/integration_test/myxql/test_helper.exs index 3db325ce7..678e9d7af 100644 --- a/integration_test/myxql/test_helper.exs +++ b/integration_test/myxql/test_helper.exs @@ -57,9 +57,7 @@ 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, - # Passes through into adapter_meta - constraint_handler: {Ecto.Integration.ConstraintsTest.CustomConstraintHandler, :to_constraints, []} + show_sensitive_data_on_connection_error: true ) defmodule Ecto.Integration.PoolRepo do @@ -87,8 +85,6 @@ _ = 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, :to_constraints, []}]) {:ok, _pid} = PoolRepo.start_link() %{rows: [[version]]} = TestRepo.query!("SELECT @@version", []) diff --git a/integration_test/pg/constraints_test.exs b/integration_test/pg/constraints_test.exs index eba231ad6..c4e68eeb1 100644 --- a/integration_test/pg/constraints_test.exs +++ b/integration_test/pg/constraints_test.exs @@ -5,15 +5,13 @@ defmodule Ecto.Integration.ConstraintsTest do alias Ecto.Integration.PoolRepo defmodule CustomConstraintHandler do - @behaviour Ecto.Adapters.SQL.Constraint - - @impl Ecto.Adapters.SQL.Constraint - # An example of a custom handler a user might write + # An example of a custom handler a user might write. + # Handles custom PG error codes from triggers, falling back to the default handler. def to_constraints( - %Postgrex.Error{postgres: %{pg_code: "ZZ001", constraint: constraint}} = _err, + %Postgrex.Error{postgres: %{pg_code: "ZZ001", constraint: constraint}}, _opts ) do - # Assumes that all pg_codes of ZZ001 are check constraint, + # Assumes that all pg_codes of ZZ001 are check constraints, # which may or may not be realistic [check: constraint] end @@ -254,26 +252,18 @@ defmodule Ecto.Integration.ConstraintsTest do :ok = up(PoolRepo, num, TriggerEmulatingConstraintMigration, log: false) end) + constraint_handler = &CustomConstraintHandler.to_constraints/2 + 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 + # Custom handler converts the trigger error into a constraint {:error, changeset} = above_max_changeset |> Ecto.Changeset.check_constraint(:price, name: :price_above_max) - |> PoolRepo.insert() + |> PoolRepo.insert(constraint_handler: constraint_handler) assert changeset.errors == [ price: {"is invalid", [constraint: :check, constraint_name: "price_above_max"]} @@ -281,53 +271,24 @@ defmodule Ecto.Integration.ConstraintsTest do assert changeset.data.__meta__.state == :built - # When the changeset does expect the db error and gives a custom message - {:error, changeset} = + # Without the custom handler, the default handler doesn't recognize + # the custom error code, so the error is raised as-is + assert_raise Postgrex.Error, fn -> above_max_changeset - |> Ecto.Changeset.check_constraint(:price, - name: :price_above_max, - message: "must be less than the max price" - ) + |> Ecto.Changeset.check_constraint(:price, name: :price_above_max) |> PoolRepo.insert() + end - 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) - + # Custom handler also works on UPDATE {: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() + Ecto.Changeset.change(item, price: 100) + |> Ecto.Changeset.check_constraint(:price, name: :price_above_max) + |> PoolRepo.update(constraint_handler: constraint_handler) assert changeset.errors == [ - price: - {"must be less than the max price", - [constraint: :check, constraint_name: "price_above_max"]} + price: {"is invalid", [constraint: :check, constraint_name: "price_above_max"]} ] assert changeset.data.__meta__.state == :loaded end -end +end \ No newline at end of file diff --git a/integration_test/pg/test_helper.exs b/integration_test/pg/test_helper.exs index 696c216be..256bb8439 100644 --- a/integration_test/pg/test_helper.exs +++ b/integration_test/pg/test_helper.exs @@ -67,16 +67,7 @@ pool_repo_config = [ max_seconds: 10 ] -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, PoolRepo, pool_repo_config) Application.put_env( :ecto_sql, @@ -109,8 +100,6 @@ _ = Ecto.Adapters.Postgres.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, :to_constraints, []}]) {:ok, _pid} = PoolRepo.start_link() {:ok, _pid} = AdvisoryLockPoolRepo.start_link() diff --git a/integration_test/tds/constraints_test.exs b/integration_test/tds/constraints_test.exs index 01700f325..00c7a538f 100644 --- a/integration_test/tds/constraints_test.exs +++ b/integration_test/tds/constraints_test.exs @@ -4,31 +4,6 @@ defmodule Ecto.Integration.ConstraintsTest do import Ecto.Migrator, only: [up: 4] alias Ecto.Integration.PoolRepo - 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 @@ -53,110 +28,6 @@ defmodule Ecto.Integration.ConstraintsTest do 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 - defmodule Constraint do use Ecto.Integration.Schema @@ -242,103 +113,4 @@ 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, 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: 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 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 +end \ No newline at end of file diff --git a/integration_test/tds/test_helper.exs b/integration_test/tds/test_helper.exs index 436db0f7f..3f0c2ce36 100644 --- a/integration_test/tds/test_helper.exs +++ b/integration_test/tds/test_helper.exs @@ -126,9 +126,7 @@ Application.put_env( PoolRepo, url: "#{Application.get_env(:ecto_sql, :tds_test_url)}/ecto_test", pool_size: 10, - set_allow_snapshot_isolation: :on, - # Passes through into adapter_meta - constraint_handler: {Ecto.Integration.ConstraintsTest.CustomConstraintHandler, :to_constraints, []} + set_allow_snapshot_isolation: :on ) defmodule Ecto.Integration.PoolRepo do diff --git a/lib/ecto/adapters/myxql/connection.ex b/lib/ecto/adapters/myxql/connection.ex index 79fc2fab6..c560d3b2f 100644 --- a/lib/ecto/adapters/myxql/connection.ex +++ b/lib/ecto/adapters/myxql/connection.ex @@ -4,7 +4,6 @@ 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 079bf0f53..f5d3faf60 100644 --- a/lib/ecto/adapters/postgres/connection.ex +++ b/lib/ecto/adapters/postgres/connection.ex @@ -4,7 +4,6 @@ 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 3da058da3..287f6bfce 100644 --- a/lib/ecto/adapters/sql.ex +++ b/lib/ecto/adapters/sql.ex @@ -401,10 +401,9 @@ 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 corresponding constraint errors. The constraints are in the keyword list and must return the constraint type, like `:unique`, and the constraint name as @@ -417,27 +416,31 @@ defmodule Ecto.Adapters.SQL do 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: + * `:constraint_handler` - a function that receives the exception and + error options and returns a keyword list of constraints. Defaults to + the adapter connection module's `to_constraints/2`. - * `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. + The `:constraint_handler` option can be set per operation or globally + via `c:Ecto.Repo.default_options/1` or `c:Ecto.Repo.prepare_options/2`. ## Examples # Postgres - iex> MyRepo.to_constraints(%Postgrex.Error{code: :unique, constraint: "posts_title_index"}, []) + 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."}, []) + iex> MyRepo.to_constraints(%MyXQL.Error{mysql: %{name: :ER_CHECK_CONSTRAINT_VIOLATED}, message: "Check constraint 'positive_price' is violated."}, [], []) [check: "positive_price"] + + # Custom handler per operation + MyRepo.insert(changeset, constraint_handler: fn + %Postgrex.Error{postgres: %{pg_code: "ZZ001", constraint: name}}, _opts -> + [check: name] + err, opts -> + Ecto.Adapters.Postgres.Connection.to_constraints(err, opts) + end) """ @explain_doc """ @@ -727,10 +730,14 @@ defmodule Ecto.Adapters.SQL do end def to_constraints(adapter_meta, err, opts, err_opts) do - %{constraint_handler: constraint_handler} = adapter_meta - {constraint_mod, fun, args} = Keyword.get(opts, :constraint_handler) || constraint_handler - args = [err, err_opts | args] - apply(constraint_mod, fun, args) + case Keyword.get(opts, :constraint_handler) do + handler when is_function(handler, 2) -> + handler.(err, err_opts) + + nil -> + %{sql: connection} = adapter_meta + connection.to_constraints(err, err_opts) + end end defp sql_call(adapter_meta, callback, args, params, opts) do @@ -959,9 +966,6 @@ defmodule Ecto.Adapters.SQL do """ end - 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]} @@ -974,7 +978,6 @@ 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) diff --git a/lib/ecto/adapters/sql/connection.ex b/lib/ecto/adapters/sql/connection.ex index 05be0fc9f..19eee1587 100644 --- a/lib/ecto/adapters/sql/connection.ex +++ b/lib/ecto/adapters/sql/connection.ex @@ -52,6 +52,20 @@ 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 deleted file mode 100644 index b700b1a15..000000000 --- a/lib/ecto/adapters/sql/constraint.ex +++ /dev/null @@ -1,21 +0,0 @@ -defmodule Ecto.Adapters.SQL.Constraint do - # TODO - add more docs around setting `:constraint_handler` globally - - @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 diff --git a/lib/ecto/adapters/tds/connection.ex b/lib/ecto/adapters/tds/connection.ex index c47908b90..be1cf229b 100644 --- a/lib/ecto/adapters/tds/connection.ex +++ b/lib/ecto/adapters/tds/connection.ex @@ -6,7 +6,6 @@ if Code.ensure_loaded?(Tds) do alias Ecto.Adapters.SQL @behaviour Ecto.Adapters.SQL.Connection - @behaviour Ecto.Adapters.SQL.Constraint @impl true def child_spec(opts) do diff --git a/test/ecto/adapters/sql_test.exs b/test/ecto/adapters/sql_test.exs new file mode 100644 index 000000000..0e42bdcd0 --- /dev/null +++ b/test/ecto/adapters/sql_test.exs @@ -0,0 +1,62 @@ +defmodule Ecto.Adapters.SQLTest.FakeError do + defexception [:type, :name, :message] +end + +defmodule Ecto.Adapters.SQLTest.FakeConnection do + alias Ecto.Adapters.SQLTest.FakeError + + def to_constraints(%FakeError{type: :unique, name: name}, _opts), do: [unique: name] + def to_constraints(%FakeError{type: :check, name: name}, _opts), do: [check: name] + def to_constraints(_err, _opts), do: [] +end + +defmodule Ecto.Adapters.SQLTest.CustomHandler do + alias Ecto.Adapters.SQLTest.{FakeError, FakeConnection} + + def to_constraints(%FakeError{type: :other, name: name}, _opts), do: [exclusion: name] + def to_constraints(err, opts), do: FakeConnection.to_constraints(err, opts) +end + +defmodule Ecto.Adapters.SQLTest do + use ExUnit.Case, async: true + + alias Ecto.Adapters.SQLTest.{FakeError, FakeConnection, CustomHandler} + + @adapter_meta %{sql: FakeConnection} + @unique_err %FakeError{type: :unique, name: "users_email_index", message: "unique violation"} + @custom_err %FakeError{type: :other, name: "cannot_overlap", message: "overlap"} + + defp to_constraints(err, opts \\ []) do + Ecto.Adapters.SQL.to_constraints(@adapter_meta, err, opts, source: "test") + end + + describe "to_constraints/4" do + test "uses the adapter connection's to_constraints/2 by default" do + assert to_constraints(@unique_err) == [unique: "users_email_index"] + end + + test "returns empty list when no constraint matches the default handler" do + assert to_constraints(@custom_err) == [] + end + + test "custom handler handles errors the default handler wouldn't" do + assert to_constraints(@custom_err, constraint_handler: &CustomHandler.to_constraints/2) == + [exclusion: "cannot_overlap"] + end + + test "custom handler can fall back to the default handler" do + assert to_constraints(@unique_err, constraint_handler: &CustomHandler.to_constraints/2) == + [unique: "users_email_index"] + end + + test "passes error options to the constraint handler" do + handler = fn _err, opts -> + send(self(), {:handler_opts, opts}) + [] + end + + to_constraints(@custom_err, constraint_handler: handler) + assert_received {:handler_opts, [source: "test"]} + end + end +end From 6b99c3d8cc7a0533e8f78c6b9df150dcdd9019fc Mon Sep 17 00:00:00 2001 From: Peter Mueller <6015288+petermueller@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:53:26 -0400 Subject: [PATCH 10/10] revert TDS constraint test --- integration_test/pg/constraints_test.exs | 2 +- integration_test/tds/constraints_test.exs | 81 +++++------------------ 2 files changed, 17 insertions(+), 66 deletions(-) diff --git a/integration_test/pg/constraints_test.exs b/integration_test/pg/constraints_test.exs index c4e68eeb1..8735e24d0 100644 --- a/integration_test/pg/constraints_test.exs +++ b/integration_test/pg/constraints_test.exs @@ -291,4 +291,4 @@ defmodule Ecto.Integration.ConstraintsTest do assert changeset.data.__meta__.state == :loaded end -end \ No newline at end of file +end diff --git a/integration_test/tds/constraints_test.exs b/integration_test/tds/constraints_test.exs index 00c7a538f..c66303e62 100644 --- a/integration_test/tds/constraints_test.exs +++ b/integration_test/tds/constraints_test.exs @@ -4,7 +4,7 @@ defmodule Ecto.Integration.ConstraintsTest do import Ecto.Migrator, only: [up: 4] alias Ecto.Integration.PoolRepo - defmodule ConstraintTableMigration do + defmodule ConstraintMigration do use Ecto.Migration @table table(:constraints_test) @@ -15,16 +15,7 @@ defmodule Ecto.Integration.ConstraintsTest do add :from, :integer add :to, :integer end - end - end - - defmodule CheckConstraintMigration do - use Ecto.Migration - - @table table(:constraints_test) - - def change do - create constraint(@table.name, :positive_price, check: "[price] > 0") + create constraint(@table.name, :cannot_overlap, check: "[from] < [to]") end end @@ -43,74 +34,34 @@ defmodule Ecto.Integration.ConstraintsTest do setup_all do ExUnit.CaptureLog.capture_log(fn -> num = @base_migration + System.unique_integer([:positive]) - up(PoolRepo, num, ConstraintTableMigration, log: false) + up(PoolRepo, num, ConstraintMigration, log: false) end) :ok end - @tag :create_constraint test "check constraint" do - num = @base_migration + System.unique_integer([:positive]) + changeset = Ecto.Changeset.change(%Constraint{}, from: 0, to: 10) + {:ok, _} = PoolRepo.insert(changeset) - ExUnit.CaptureLog.capture_log(fn -> - :ok = up(PoolRepo, num, CheckConstraintMigration, log: false) - end) + non_overlapping_changeset = Ecto.Changeset.change(%Constraint{}, from: 11, to: 12) + {:ok, _} = PoolRepo.insert(non_overlapping_changeset) - # When the changeset doesn't expect the db error - changeset = Ecto.Changeset.change(%Constraint{}, price: -10) + overlapping_changeset = Ecto.Changeset.change(%Constraint{}, from: 1900, to: 12) 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_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 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) + overlapping_changeset + |> Ecto.Changeset.check_constraint(:from, name: :cannot_overlap) |> 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.errors == [from: {"is invalid", [constraint: :check, constraint_name: "cannot_overlap"]}] 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 -end \ No newline at end of file +end