Skip to content

Commit 2d3173e

Browse files
committed
allow :constraint_handler option
defaults to existing adapter connection module
1 parent 48fc2ad commit 2d3173e

File tree

4 files changed

+203
-14
lines changed

4 files changed

+203
-14
lines changed

integration_test/myxql/constraints_test.exs

+177-9
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,29 @@ defmodule Ecto.Integration.ConstraintsTest do
44
import Ecto.Migrator, only: [up: 4]
55
alias Ecto.Integration.PoolRepo
66

7+
defmodule CustomConstraintHandler do
8+
@quotes ~w(" ' `)
9+
10+
# An example of a custom handler a user might write
11+
def to_constraints(%MyXQL.Error{mysql: %{name: :ER_SIGNAL_EXCEPTION}, message: message}, opts) do
12+
# Assumes this is the only use-case of `ER_SIGNAL_EXCEPTION` the user has implemented custom errors for
13+
with [_, quoted] <- :binary.split(message, "Overlapping values for key "),
14+
[_, index | _] <- :binary.split(quoted, @quotes, [:global]) do
15+
[exclusion: strip_source(index, opts[:source])]
16+
else
17+
_ -> []
18+
end
19+
end
20+
21+
def to_constraints(err, opts) do
22+
# Falls back to default `ecto_sql` handler for all others
23+
Ecto.Adapters.MyXQL.Connection.to_constraints(err, opts)
24+
end
25+
26+
defp strip_source(name, nil), do: name
27+
defp strip_source(name, source), do: String.trim_leading(name, "#{source}.")
28+
end
29+
730
defmodule ConstraintMigration do
831
use Ecto.Migration
932

@@ -21,6 +44,50 @@ defmodule Ecto.Integration.ConstraintsTest do
2144
end
2245
end
2346

47+
defmodule ProcedureEmulatingConstraintMigration do
48+
use Ecto.Migration
49+
50+
@table_name :constraints_test
51+
52+
def up do
53+
insert_trigger_sql = trigger_sql(@table_name, "INSERT")
54+
update_trigger_sql = trigger_sql(@table_name, "UPDATE")
55+
56+
drop_triggers(@table_name)
57+
repo().query!(insert_trigger_sql)
58+
repo().query!(update_trigger_sql)
59+
end
60+
61+
def down do
62+
drop_triggers(@table_name)
63+
end
64+
65+
defp trigger_sql(table_name, before_type) do
66+
~s"""
67+
CREATE TRIGGER #{table_name}_#{String.downcase(before_type)}_overlap
68+
BEFORE #{String.upcase(before_type)}
69+
ON #{table_name} FOR EACH ROW
70+
BEGIN
71+
DECLARE v_rowcount INT;
72+
DECLARE v_msg VARCHAR(200);
73+
74+
SELECT COUNT(*) INTO v_rowcount FROM #{table_name}
75+
WHERE (NEW.from <= `to` AND NEW.to >= `from`);
76+
77+
IF v_rowcount > 0 THEN
78+
SET v_msg = CONCAT('Overlapping values for key \\'#{table_name}.cannot_overlap\\'');
79+
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = v_msg, MYSQL_ERRNO = 1644;
80+
END IF;
81+
END;
82+
"""
83+
end
84+
85+
defp drop_triggers(table_name) do
86+
repo().query!("DROP TRIGGER IF EXISTS #{table_name}_insert_overlap")
87+
repo().query!("DROP TRIGGER IF EXISTS #{table_name}_update_overlap")
88+
end
89+
end
90+
2491
defmodule Constraint do
2592
use Ecto.Integration.Schema
2693

@@ -31,12 +98,23 @@ defmodule Ecto.Integration.ConstraintsTest do
3198
end
3299
end
33100

101+
defmodule CustomConstraint do
102+
use Ecto.Integration.Schema
103+
104+
schema "procedure_constraints_test" do
105+
field :member_id, :integer
106+
field :started_at, :utc_datetime_usec
107+
field :ended_at, :utc_datetime_usec
108+
end
109+
end
110+
34111
@base_migration 2_000_000
35112

36113
setup_all do
37114
ExUnit.CaptureLog.capture_log(fn ->
38115
num = @base_migration + System.unique_integer([:positive])
39116
up(PoolRepo, num, ConstraintMigration, log: false)
117+
up(PoolRepo, num + 1, ProcedureEmulatingConstraintMigration, log: false)
40118
end)
41119

42120
:ok
@@ -46,10 +124,13 @@ defmodule Ecto.Integration.ConstraintsTest do
46124
test "check constraint" do
47125
# When the changeset doesn't expect the db error
48126
changeset = Ecto.Changeset.change(%Constraint{}, price: -10)
127+
49128
exception =
50-
assert_raise Ecto.ConstraintError, ~r/constraint error when attempting to insert struct/, fn ->
51-
PoolRepo.insert(changeset)
52-
end
129+
assert_raise Ecto.ConstraintError,
130+
~r/constraint error when attempting to insert struct/,
131+
fn ->
132+
PoolRepo.insert(changeset)
133+
end
53134

54135
assert exception.message =~ "\"positive_price\" (check_constraint)"
55136
assert exception.message =~ "The changeset has not defined any constraint."
@@ -60,24 +141,111 @@ defmodule Ecto.Integration.ConstraintsTest do
60141
changeset
61142
|> Ecto.Changeset.check_constraint(:price, name: :positive_price)
62143
|> PoolRepo.insert()
63-
assert changeset.errors == [price: {"is invalid", [constraint: :check, constraint_name: "positive_price"]}]
144+
145+
assert changeset.errors == [
146+
price: {"is invalid", [constraint: :check, constraint_name: "positive_price"]}
147+
]
148+
64149
assert changeset.data.__meta__.state == :built
65150

66151
# When the changeset does expect the db error and gives a custom message
67152
changeset = Ecto.Changeset.change(%Constraint{}, price: -10)
153+
68154
{:error, changeset} =
69155
changeset
70-
|> Ecto.Changeset.check_constraint(:price, name: :positive_price, message: "price must be greater than 0")
156+
|> Ecto.Changeset.check_constraint(:price,
157+
name: :positive_price,
158+
message: "price must be greater than 0"
159+
)
71160
|> PoolRepo.insert()
72-
assert changeset.errors == [price: {"price must be greater than 0", [constraint: :check, constraint_name: "positive_price"]}]
161+
162+
assert changeset.errors == [
163+
price:
164+
{"price must be greater than 0",
165+
[constraint: :check, constraint_name: "positive_price"]}
166+
]
167+
73168
assert changeset.data.__meta__.state == :built
74169

75170
# When the change does not violate the check constraint
76171
changeset = Ecto.Changeset.change(%Constraint{}, price: 10, from: 100, to: 200)
77-
{:ok, changeset} =
172+
173+
{:ok, result} =
78174
changeset
79-
|> Ecto.Changeset.check_constraint(:price, name: :positive_price, message: "price must be greater than 0")
175+
|> Ecto.Changeset.check_constraint(:price,
176+
name: :positive_price,
177+
message: "price must be greater than 0"
178+
)
179+
|> PoolRepo.insert()
180+
181+
assert is_integer(result.id)
182+
end
183+
184+
test "custom handled constraint" do
185+
changeset = Ecto.Changeset.change(%Constraint{}, from: 0, to: 10)
186+
{:ok, item} = PoolRepo.insert(changeset)
187+
188+
non_overlapping_changeset = Ecto.Changeset.change(%Constraint{}, from: 11, to: 12)
189+
{:ok, _} = PoolRepo.insert(non_overlapping_changeset)
190+
191+
overlapping_changeset = Ecto.Changeset.change(%Constraint{}, from: 9, to: 12)
192+
193+
msg_re = ~r/constraint error when attempting to insert struct/
194+
195+
# When the changeset doesn't expect the db error
196+
exception =
197+
assert_raise Ecto.ConstraintError, msg_re, fn -> PoolRepo.insert(overlapping_changeset) end
198+
199+
assert exception.message =~ "\"cannot_overlap\" (exclusion_constraint)"
200+
assert exception.message =~ "The changeset has not defined any constraint."
201+
assert exception.message =~ "call `exclusion_constraint/3`"
202+
203+
#####
204+
205+
# When the changeset does expect the db error
206+
# but the key does not match the default generated by `exclusion_constraint`
207+
exception =
208+
assert_raise Ecto.ConstraintError, msg_re, fn ->
209+
overlapping_changeset
210+
|> Ecto.Changeset.exclusion_constraint(:from)
211+
|> PoolRepo.insert()
212+
end
213+
assert exception.message =~ "\"cannot_overlap\" (exclusion_constraint)"
214+
215+
# When the changeset does expect the db error, but doesn't give a custom message
216+
{:error, changeset} =
217+
overlapping_changeset
218+
|> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap)
219+
|> PoolRepo.insert()
220+
assert changeset.errors == [from: {"violates an exclusion constraint", [constraint: :exclusion, constraint_name: "cannot_overlap"]}]
221+
assert changeset.data.__meta__.state == :built
222+
223+
# When the changeset does expect the db error and gives a custom message
224+
{:error, changeset} =
225+
overlapping_changeset
226+
|> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap, message: "must not overlap")
227+
|> PoolRepo.insert()
228+
assert changeset.errors == [from: {"must not overlap", [constraint: :exclusion, constraint_name: "cannot_overlap"]}]
229+
assert changeset.data.__meta__.state == :built
230+
231+
232+
# When the changeset does expect the db error, but a different handler is used
233+
exception =
234+
assert_raise MyXQL.Error, fn ->
235+
overlapping_changeset
236+
|> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap)
237+
|> PoolRepo.insert(constraint_handler: Ecto.Adapters.MyXQL.Connection)
238+
end
239+
assert exception.message =~ "Overlapping values for key 'constraints_test.cannot_overlap'"
240+
241+
# When custom error is coming from an UPDATE
242+
overlapping_update_changeset = Ecto.Changeset.change(item, from: 0, to: 9)
243+
244+
{:error, changeset} =
245+
overlapping_update_changeset
246+
|> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap, message: "must not overlap")
80247
|> PoolRepo.insert()
81-
assert is_integer(changeset.id)
248+
assert changeset.errors == [from: {"must not overlap", [constraint: :exclusion, constraint_name: "cannot_overlap"]}]
249+
assert changeset.data.__meta__.state == :loaded
82250
end
83251
end

integration_test/myxql/test_helper.exs

+6-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ Application.put_env(:ecto_sql, PoolRepo,
5454
adapter: Ecto.Adapters.MyXQL,
5555
url: Application.get_env(:ecto_sql, :mysql_test_url) <> "/ecto_test",
5656
pool_size: 10,
57-
show_sensitive_data_on_connection_error: true
57+
show_sensitive_data_on_connection_error: true,
58+
# Passes through into adapter_meta
59+
constraint_handler: Ecto.Integration.ConstraintsTest.CustomConstraintHandler
5860
)
5961

6062
defmodule Ecto.Integration.PoolRepo do
@@ -81,6 +83,9 @@ _ = Ecto.Adapters.MyXQL.storage_down(TestRepo.config())
8183
:ok = Ecto.Adapters.MyXQL.storage_up(TestRepo.config())
8284

8385
{:ok, _pid} = TestRepo.start_link()
86+
87+
# Passes through into adapter_meta, overrides Application config
88+
# {:ok, _pid} = PoolRepo.start_link([constraint_handler: Ecto.Integration.ConstraintsTest.CustomConstraintHandler])
8489
{:ok, _pid} = PoolRepo.start_link()
8590

8691
%{rows: [[version]]} = TestRepo.query!("SELECT @@version", [])

lib/ecto/adapters/myxql.ex

+1-1
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ defmodule Ecto.Adapters.MyXQL do
315315
{:ok, last_insert_id(key, last_insert_id)}
316316

317317
{:error, err} ->
318-
case @conn.to_constraints(err, source: source) do
318+
case Ecto.Adapters.SQL.to_constraints(adapter_meta, opts, err, source: source) do
319319
[] -> raise err
320320
constraints -> {:invalid, constraints}
321321
end

lib/ecto/adapters/sql.ex

+19-3
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,12 @@ defmodule Ecto.Adapters.SQL do
644644
sql_call(adapter_meta, :query_many, [sql], params, opts)
645645
end
646646

647+
def to_constraints(adapter_meta, opts, err, err_opts) do
648+
%{constraint_handler: constraint_handler} = adapter_meta
649+
constraint_handler = Keyword.get(opts, :constraint_handler) || constraint_handler
650+
constraint_handler.to_constraints(err, err_opts)
651+
end
652+
647653
defp sql_call(adapter_meta, callback, args, params, opts) do
648654
%{pid: pool, telemetry: telemetry, sql: sql, opts: default_opts} = adapter_meta
649655
conn = get_conn_or_pool(pool)
@@ -868,13 +874,23 @@ defmodule Ecto.Adapters.SQL do
868874
"""
869875
end
870876

877+
constraint_handler = Keyword.get(config, :constraint_handler, connection)
871878
stacktrace = Keyword.get(config, :stacktrace, nil)
872879
telemetry_prefix = Keyword.fetch!(config, :telemetry_prefix)
873880
telemetry = {config[:repo], log, telemetry_prefix ++ [:query]}
874881

875882
config = adapter_config(config)
883+
876884
opts = Keyword.take(config, @pool_opts)
877-
meta = %{telemetry: telemetry, sql: connection, stacktrace: stacktrace, opts: opts}
885+
886+
meta = %{
887+
telemetry: telemetry,
888+
sql: connection,
889+
constraint_handler: constraint_handler,
890+
stacktrace: stacktrace,
891+
opts: opts
892+
}
893+
878894
{:ok, connection.child_spec(config), meta}
879895
end
880896

@@ -1112,7 +1128,7 @@ defmodule Ecto.Adapters.SQL do
11121128
@doc false
11131129
def struct(
11141130
adapter_meta,
1115-
conn,
1131+
_conn,
11161132
sql,
11171133
operation,
11181134
source,
@@ -1147,7 +1163,7 @@ defmodule Ecto.Adapters.SQL do
11471163
operation: operation
11481164

11491165
{:error, err} ->
1150-
case conn.to_constraints(err, source: source) do
1166+
case to_constraints(adapter_meta, opts, err, source: source) do
11511167
[] -> raise_sql_call_error(err)
11521168
constraints -> {:invalid, constraints}
11531169
end

0 commit comments

Comments
 (0)