Skip to content

Commit c17e146

Browse files
committed
WIP - test tds custom constraint handlers
1 parent 2931996 commit c17e146

File tree

2 files changed

+292
-13
lines changed

2 files changed

+292
-13
lines changed

integration_test/tds/constraints_test.exs

Lines changed: 289 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,32 @@ defmodule Ecto.Integration.ConstraintsTest do
44
import Ecto.Migrator, only: [up: 4]
55
alias Ecto.Integration.PoolRepo
66

7-
defmodule ConstraintMigration do
7+
defmodule CustomConstraintHandler do
8+
@behaviour Ecto.Adapters.SQL.Constraint
9+
10+
@impl Ecto.Adapters.SQL.Constraint
11+
# An example of a custom handler a user might write
12+
def to_constraints(%Tds.Error{mssql: %{number: 50000, msg_text: message}}, opts) do
13+
# Assumes this is the only use-case of error 50000 the user has implemented custom errors for
14+
# Message format: "Overlapping values for key 'cannot_overlap'"
15+
with [_, quoted] <- :binary.split(message, "Overlapping values for key "),
16+
[_, index | _] <- :binary.split(quoted, ["'"]) do
17+
[exclusion: strip_source(index, opts[:source])]
18+
else
19+
_ -> []
20+
end
21+
end
22+
23+
def to_constraints(err, opts) do
24+
# Falls back to default `ecto_sql` handler for all others
25+
Ecto.Adapters.Tds.Connection.to_constraints(err, opts)
26+
end
27+
28+
defp strip_source(name, nil), do: name
29+
defp strip_source(name, source), do: String.trim_leading(name, "#{source}.")
30+
end
31+
32+
defmodule ConstraintTableMigration do
833
use Ecto.Migration
934

1035
@table table(:constraints_test)
@@ -15,7 +40,120 @@ defmodule Ecto.Integration.ConstraintsTest do
1540
add :from, :integer
1641
add :to, :integer
1742
end
18-
create constraint(@table.name, :cannot_overlap, check: "[from] < [to]")
43+
end
44+
end
45+
46+
defmodule CheckConstraintMigration do
47+
use Ecto.Migration
48+
49+
@table table(:constraints_test)
50+
51+
def change do
52+
create constraint(@table.name, :positive_price, check: "[price] > 0")
53+
end
54+
end
55+
56+
defmodule TriggerEmulatingConstraintMigration do
57+
use Ecto.Migration
58+
59+
@table_name :constraints_test
60+
61+
def up do
62+
insert_trigger_sql = trigger_sql(@table_name, "INSERT")
63+
update_trigger_sql = trigger_sql(@table_name, "UPDATE")
64+
65+
drop_triggers(@table_name)
66+
repo().query!(insert_trigger_sql)
67+
repo().query!(update_trigger_sql)
68+
end
69+
70+
def down do
71+
drop_triggers(@table_name)
72+
end
73+
74+
# Set-based INSTEAD OF trigger for MSSQL (handles multiple rows)
75+
# Uses INSTEAD OF to work with Ecto's OUTPUT clause (AFTER triggers conflict with OUTPUT)
76+
defp trigger_sql(table_name, before_type) do
77+
~s"""
78+
CREATE TRIGGER #{table_name}_#{String.downcase(before_type)}_overlap
79+
ON #{table_name}
80+
INSTEAD OF #{String.upcase(before_type)}
81+
AS
82+
BEGIN
83+
DECLARE @v_rowcount INT;
84+
85+
DECLARE @OutputTable TABLE (
86+
ID INT,
87+
price INT,
88+
[from] INT,
89+
[to] INT
90+
);
91+
92+
-- Check for overlaps between inserted rows and existing rows
93+
IF '#{before_type}' = 'INSERT'
94+
BEGIN
95+
-- For INSERT: check against existing rows
96+
SELECT @v_rowcount = COUNT(*)
97+
FROM inserted i
98+
INNER JOIN #{table_name} t
99+
ON (i.[from] <= t.[to] AND i.[to] >= t.[from]);
100+
END
101+
ELSE
102+
BEGIN
103+
-- For UPDATE: check against existing rows except the one being updated
104+
SELECT @v_rowcount = COUNT(*)
105+
FROM inserted i
106+
INNER JOIN #{table_name} t
107+
ON (i.[from] <= t.[to] AND i.[to] >= t.[from])
108+
AND t.id NOT IN (SELECT id FROM deleted);
109+
END
110+
111+
-- Also check for overlaps within the inserted set itself
112+
IF @v_rowcount = 0
113+
BEGIN
114+
SELECT @v_rowcount = COUNT(*)
115+
FROM inserted i1
116+
INNER JOIN inserted i2
117+
ON (i1.[from] <= i2.[to] AND i1.[to] >= i2.[from])
118+
AND i1.id != i2.id;
119+
END
120+
121+
IF @v_rowcount > 0
122+
BEGIN
123+
DECLARE @v_msg NVARCHAR(200);
124+
SET @v_msg = 'Overlapping values for key ''#{table_name}.cannot_overlap''';
125+
THROW 50000, @v_msg, 1;
126+
RETURN;
127+
END
128+
129+
IF '#{before_type}' = 'INSERT'
130+
BEGIN
131+
INSERT INTO #{table_name} (ID, price, [from], [to])
132+
OUTPUT INSERTED.ID, INSERTED.price, INSERTED.[from], INSERTED.[to] INTO @OutputTable (ID, price, [from], [to])
133+
SELECT i3.ID, i3.price, i3.[from], i3.[to] FROM inserted i3;
134+
SELECT * FROM @OutputTable;
135+
END
136+
ELSE
137+
BEGIN
138+
UPDATE t2
139+
SET t2.price = i4.price,
140+
t2.[from] = i4.[from],
141+
t2.[to] = i4.[to]
142+
FROM #{table_name} t2
143+
INNER JOIN inserted i4 ON t2.id = i4.id;
144+
END
145+
END;
146+
"""
147+
end
148+
149+
defp drop_triggers(table_name) do
150+
repo().query!(
151+
"IF OBJECT_ID('#{table_name}_insert_overlap', 'TR') IS NOT NULL DROP TRIGGER #{table_name}_insert_overlap"
152+
)
153+
154+
repo().query!(
155+
"IF OBJECT_ID('#{table_name}_update_overlap', 'TR') IS NOT NULL DROP TRIGGER #{table_name}_update_overlap"
156+
)
19157
end
20158
end
21159

@@ -34,34 +172,173 @@ defmodule Ecto.Integration.ConstraintsTest do
34172
setup_all do
35173
ExUnit.CaptureLog.capture_log(fn ->
36174
num = @base_migration + System.unique_integer([:positive])
37-
up(PoolRepo, num, ConstraintMigration, log: false)
175+
up(PoolRepo, num, ConstraintTableMigration, log: false)
38176
end)
39177

40178
:ok
41179
end
42180

181+
@tag :create_constraint
43182
test "check constraint" do
183+
num = @base_migration + System.unique_integer([:positive])
184+
185+
ExUnit.CaptureLog.capture_log(fn ->
186+
:ok = up(PoolRepo, num, CheckConstraintMigration, log: false)
187+
end)
188+
189+
# When the changeset doesn't expect the db error
190+
changeset = Ecto.Changeset.change(%Constraint{}, price: -10)
191+
192+
exception =
193+
assert_raise Ecto.ConstraintError,
194+
~r/constraint error when attempting to insert struct/,
195+
fn -> PoolRepo.insert(changeset) end
196+
197+
assert exception.message =~ "\"positive_price\" (check_constraint)"
198+
assert exception.message =~ "The changeset has not defined any constraint."
199+
assert exception.message =~ "call `check_constraint/3`"
200+
201+
# When the changeset does expect the db error, but doesn't give a custom message
202+
{:error, changeset} =
203+
changeset
204+
|> Ecto.Changeset.check_constraint(:price, name: :positive_price)
205+
|> PoolRepo.insert()
206+
207+
assert changeset.errors == [
208+
price: {"is invalid", [constraint: :check, constraint_name: "positive_price"]}
209+
]
210+
211+
assert changeset.data.__meta__.state == :built
212+
213+
# When the changeset does expect the db error and gives a custom message
214+
changeset = Ecto.Changeset.change(%Constraint{}, price: -10)
215+
216+
{:error, changeset} =
217+
changeset
218+
|> Ecto.Changeset.check_constraint(:price,
219+
name: :positive_price,
220+
message: "price must be greater than 0"
221+
)
222+
|> PoolRepo.insert()
223+
224+
assert changeset.errors == [
225+
price:
226+
{"price must be greater than 0",
227+
[constraint: :check, constraint_name: "positive_price"]}
228+
]
229+
230+
assert changeset.data.__meta__.state == :built
231+
232+
# When the change does not violate the check constraint
233+
changeset = Ecto.Changeset.change(%Constraint{}, price: 10, from: 100, to: 200)
234+
235+
{:ok, result} =
236+
changeset
237+
|> Ecto.Changeset.check_constraint(:price,
238+
name: :positive_price,
239+
message: "price must be greater than 0"
240+
)
241+
|> PoolRepo.insert()
242+
243+
assert is_integer(result.id)
244+
end
245+
246+
@tag :constraint_handler
247+
test "custom handled constraint" do
248+
num = @base_migration + System.unique_integer([:positive])
249+
250+
ExUnit.CaptureLog.capture_log(fn ->
251+
:ok = up(PoolRepo, num, TriggerEmulatingConstraintMigration, log: false)
252+
end)
253+
44254
changeset = Ecto.Changeset.change(%Constraint{}, from: 0, to: 10)
45-
{:ok, _} = PoolRepo.insert(changeset)
255+
256+
{:ok, item} = PoolRepo.insert(changeset, returning: false)
46257

47258
non_overlapping_changeset = Ecto.Changeset.change(%Constraint{}, from: 11, to: 12)
48259
{:ok, _} = PoolRepo.insert(non_overlapping_changeset)
49260

50-
overlapping_changeset = Ecto.Changeset.change(%Constraint{}, from: 1900, to: 12)
261+
overlapping_changeset = Ecto.Changeset.change(%Constraint{}, from: 9, to: 12)
51262

263+
msg_re = ~r/constraint error when attempting to insert struct/
264+
265+
# When the changeset doesn't expect the db error
52266
exception =
53-
assert_raise Ecto.ConstraintError, ~r/constraint error when attempting to insert struct/, fn ->
54-
PoolRepo.insert(overlapping_changeset)
55-
end
56-
assert exception.message =~ "\"cannot_overlap\" (check_constraint)"
267+
assert_raise Ecto.ConstraintError, msg_re, fn -> PoolRepo.insert(overlapping_changeset) end
268+
269+
assert exception.message =~ "\"cannot_overlap\" (exclusion_constraint)"
57270
assert exception.message =~ "The changeset has not defined any constraint."
58-
assert exception.message =~ "call `check_constraint/3`"
271+
assert exception.message =~ "call `exclusion_constraint/3`"
272+
273+
# When the changeset does expect the db error
274+
# but the key does not match the default generated by `exclusion_constraint`
275+
exception =
276+
assert_raise Ecto.ConstraintError, msg_re, fn ->
277+
overlapping_changeset
278+
|> Ecto.Changeset.exclusion_constraint(:from)
279+
|> PoolRepo.insert()
280+
end
281+
282+
assert exception.message =~ "\"cannot_overlap\" (exclusion_constraint)"
59283

284+
# When the changeset does expect the db error, but doesn't give a custom message
60285
{:error, changeset} =
61286
overlapping_changeset
62-
|> Ecto.Changeset.check_constraint(:from, name: :cannot_overlap)
287+
|> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap)
63288
|> PoolRepo.insert()
64-
assert changeset.errors == [from: {"is invalid", [constraint: :check, constraint_name: "cannot_overlap"]}]
289+
290+
assert changeset.errors == [
291+
from:
292+
{"violates an exclusion constraint",
293+
[constraint: :exclusion, constraint_name: "cannot_overlap"]}
294+
]
295+
65296
assert changeset.data.__meta__.state == :built
297+
298+
# When the changeset does expect the db error and gives a custom message
299+
{:error, changeset} =
300+
overlapping_changeset
301+
|> Ecto.Changeset.exclusion_constraint(:from,
302+
name: :cannot_overlap,
303+
message: "must not overlap"
304+
)
305+
|> PoolRepo.insert()
306+
307+
assert changeset.errors == [
308+
from:
309+
{"must not overlap", [constraint: :exclusion, constraint_name: "cannot_overlap"]}
310+
]
311+
312+
assert changeset.data.__meta__.state == :built
313+
314+
# When the changeset does expect the db error, but a different handler is used
315+
exception =
316+
assert_raise Tds.Error, fn ->
317+
overlapping_changeset
318+
|> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap)
319+
|> PoolRepo.insert(
320+
constraint_handler: {Ecto.Adapters.Tds.Connection, :to_constraints, []}
321+
)
322+
end
323+
324+
assert exception.message =~ "Overlapping values for key 'constraints_test.cannot_overlap'"
325+
326+
# When custom error is coming from an UPDATE
327+
overlapping_update_changeset = Ecto.Changeset.change(item, from: 0, to: 9)
328+
329+
{:error, changeset} =
330+
overlapping_update_changeset
331+
|> Ecto.Changeset.exclusion_constraint(:from,
332+
name: :cannot_overlap,
333+
message: "must not overlap"
334+
)
335+
|> PoolRepo.update()
336+
337+
assert changeset.errors == [
338+
from:
339+
{"must not overlap", [constraint: :exclusion, constraint_name: "cannot_overlap"]}
340+
]
341+
342+
assert changeset.data.__meta__.state == :loaded
66343
end
67344
end

integration_test/tds/test_helper.exs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,9 @@ Application.put_env(
126126
PoolRepo,
127127
url: "#{Application.get_env(:ecto_sql, :tds_test_url)}/ecto_test",
128128
pool_size: 10,
129-
set_allow_snapshot_isolation: :on
129+
set_allow_snapshot_isolation: :on,
130+
# Passes through into adapter_meta
131+
constraint_handler: {Ecto.Integration.ConstraintsTest.CustomConstraintHandler, :to_constraints, []}
130132
)
131133

132134
defmodule Ecto.Integration.PoolRepo do

0 commit comments

Comments
 (0)