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