@@ -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
67344end
0 commit comments