@@ -4,6 +4,29 @@ defmodule Ecto.Integration.ConstraintsTest do
4
4
import Ecto.Migrator , only: [ up: 4 ]
5
5
alias Ecto.Integration.PoolRepo
6
6
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
+
7
30
defmodule ConstraintMigration do
8
31
use Ecto.Migration
9
32
@@ -21,6 +44,50 @@ defmodule Ecto.Integration.ConstraintsTest do
21
44
end
22
45
end
23
46
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
+
24
91
defmodule Constraint do
25
92
use Ecto.Integration.Schema
26
93
@@ -31,12 +98,23 @@ defmodule Ecto.Integration.ConstraintsTest do
31
98
end
32
99
end
33
100
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
+
34
111
@ base_migration 2_000_000
35
112
36
113
setup_all do
37
114
ExUnit.CaptureLog . capture_log ( fn ->
38
115
num = @ base_migration + System . unique_integer ( [ :positive ] )
39
116
up ( PoolRepo , num , ConstraintMigration , log: false )
117
+ up ( PoolRepo , num + 1 , ProcedureEmulatingConstraintMigration , log: false )
40
118
end )
41
119
42
120
:ok
@@ -46,10 +124,13 @@ defmodule Ecto.Integration.ConstraintsTest do
46
124
test "check constraint" do
47
125
# When the changeset doesn't expect the db error
48
126
changeset = Ecto.Changeset . change ( % Constraint { } , price: - 10 )
127
+
49
128
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
53
134
54
135
assert exception . message =~ "\" positive_price\" (check_constraint)"
55
136
assert exception . message =~ "The changeset has not defined any constraint."
@@ -60,24 +141,111 @@ defmodule Ecto.Integration.ConstraintsTest do
60
141
changeset
61
142
|> Ecto.Changeset . check_constraint ( :price , name: :positive_price )
62
143
|> 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
+
64
149
assert changeset . data . __meta__ . state == :built
65
150
66
151
# When the changeset does expect the db error and gives a custom message
67
152
changeset = Ecto.Changeset . change ( % Constraint { } , price: - 10 )
153
+
68
154
{ :error , changeset } =
69
155
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
+ )
71
160
|> 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
+
73
168
assert changeset . data . __meta__ . state == :built
74
169
75
170
# When the change does not violate the check constraint
76
171
changeset = Ecto.Changeset . change ( % Constraint { } , price: 10 , from: 100 , to: 200 )
77
- { :ok , changeset } =
172
+
173
+ { :ok , result } =
78
174
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" )
80
247
|> 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
82
250
end
83
251
end
0 commit comments