@@ -4,7 +4,27 @@ 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 (
13+ % Postgrex.Error { postgres: % { pg_code: "ZZ001" , constraint: constraint } } = _err ,
14+ _opts
15+ ) do
16+ # Assumes that all pg_codes of ZZ001 are check constraint,
17+ # which may or may not be realistic
18+ [ check: constraint ]
19+ end
20+
21+ def to_constraints ( err , opts ) do
22+ # Falls back to default `ecto_sql` handler for all others
23+ Ecto.Adapters.Postgres.Connection . to_constraints ( err , opts )
24+ end
25+ end
26+
27+ defmodule ConstraintTableMigration do
828 use Ecto.Migration
929
1030 @ table table ( :constraints_test )
@@ -15,11 +35,81 @@ defmodule Ecto.Integration.ConstraintsTest do
1535 add :from , :integer
1636 add :to , :integer
1737 end
18- create constraint ( @ table . name , :cannot_overlap , exclude: ~s| gist (int4range("from", "to", '[]') WITH &&)| )
38+ end
39+ end
40+
41+ defmodule CheckConstraintMigration do
42+ use Ecto.Migration
43+
44+ @ table table ( :constraints_test )
45+
46+ def change do
1947 create constraint ( @ table . name , "positive_price" , check: "price > 0" )
2048 end
2149 end
2250
51+ defmodule ExclusionConstraintMigration do
52+ use Ecto.Migration
53+
54+ @ table table ( :constraints_test )
55+
56+ def change do
57+ create constraint ( @ table . name , :cannot_overlap ,
58+ exclude: ~s| gist (int4range("from", "to", '[]') WITH &&)|
59+ )
60+ end
61+ end
62+
63+ defmodule TriggerEmulatingConstraintMigration do
64+ use Ecto.Migration
65+
66+ @ table_name :constraints_test
67+
68+ def up do
69+ function_sql = ~s"""
70+ CREATE OR REPLACE FUNCTION check_price_limit()
71+ RETURNS TRIGGER AS $$
72+ BEGIN
73+ IF NEW.price + 1 > 100 THEN
74+ RAISE EXCEPTION SQLSTATE 'ZZ001'
75+ USING MESSAGE = 'price must be less than 100, got ' || NEW.price::TEXT,
76+ CONSTRAINT = 'price_above_max';
77+ END IF;
78+ RETURN NEW;
79+ END;
80+ $$ LANGUAGE plpgsql;
81+ """
82+
83+ insert_trigger_sql = trigger_sql ( @ table_name , "INSERT" )
84+ update_trigger_sql = trigger_sql ( @ table_name , "UPDATE" )
85+
86+ drop_triggers ( @ table_name )
87+ repo ( ) . query! ( function_sql )
88+ repo ( ) . query! ( insert_trigger_sql )
89+ repo ( ) . query! ( update_trigger_sql )
90+ end
91+
92+ def down do
93+ drop_triggers ( @ table_name )
94+ end
95+
96+ # not a great example, but demonstrates the feature
97+ defp trigger_sql ( table_name , before_type ) do
98+ ~s"""
99+ CREATE TRIGGER #{ table_name } _before_price_#{ String . downcase ( before_type ) }
100+ BEFORE #{ String . upcase ( before_type ) }
101+ ON #{ table_name }
102+ FOR EACH ROW
103+ EXECUTE FUNCTION check_price_limit();
104+ """
105+ end
106+
107+ defp drop_triggers ( table_name ) do
108+ repo ( ) . query! ( "DROP TRIGGER IF EXISTS #{ table_name } _before_price_insert ON #{ table_name } " )
109+ repo ( ) . query! ( "DROP TRIGGER IF EXISTS #{ table_name } _before_price_update ON #{ table_name } " )
110+ end
111+ end
112+
23113 defmodule Constraint do
24114 use Ecto.Integration.Schema
25115
@@ -35,13 +125,19 @@ defmodule Ecto.Integration.ConstraintsTest do
35125 setup_all do
36126 ExUnit.CaptureLog . capture_log ( fn ->
37127 num = @ base_migration + System . unique_integer ( [ :positive ] )
38- up ( PoolRepo , num , ConstraintMigration , log: false )
128+ up ( PoolRepo , num , ConstraintTableMigration , log: false )
39129 end )
40130
41131 :ok
42132 end
43133
44134 test "exclusion constraint" do
135+ num = @ base_migration + System . unique_integer ( [ :positive ] )
136+
137+ ExUnit.CaptureLog . capture_log ( fn ->
138+ :ok = up ( PoolRepo , num , ExclusionConstraintMigration , log: false )
139+ end )
140+
45141 changeset = Ecto.Changeset . change ( % Constraint { } , from: 0 , to: 10 )
46142 { :ok , _ } = PoolRepo . insert ( changeset )
47143
@@ -51,37 +147,55 @@ defmodule Ecto.Integration.ConstraintsTest do
51147 overlapping_changeset = Ecto.Changeset . change ( % Constraint { } , from: 9 , to: 12 )
52148
53149 exception =
54- assert_raise Ecto.ConstraintError , ~r/ constraint error when attempting to insert struct/ , fn ->
55- PoolRepo . insert ( overlapping_changeset )
56- end
150+ assert_raise Ecto.ConstraintError ,
151+ ~r/ constraint error when attempting to insert struct/ ,
152+ fn ->
153+ PoolRepo . insert ( overlapping_changeset )
154+ end
155+
57156 assert exception . message =~ "\" cannot_overlap\" (exclusion_constraint)"
58157 assert exception . message =~ "The changeset has not defined any constraint."
59158 assert exception . message =~ "call `exclusion_constraint/3`"
60159
61160 message = ~r/ constraint error when attempting to insert struct/
161+
62162 exception =
63163 assert_raise Ecto.ConstraintError , message , fn ->
64164 overlapping_changeset
65165 |> Ecto.Changeset . exclusion_constraint ( :from )
66166 |> PoolRepo . insert ( )
67167 end
168+
68169 assert exception . message =~ "\" cannot_overlap\" (exclusion_constraint)"
69170
70171 { :error , changeset } =
71172 overlapping_changeset
72173 |> Ecto.Changeset . exclusion_constraint ( :from , name: :cannot_overlap )
73174 |> PoolRepo . insert ( )
74- assert changeset . errors == [ from: { "violates an exclusion constraint" , [ constraint: :exclusion , constraint_name: "cannot_overlap" ] } ]
175+
176+ assert changeset . errors == [
177+ from:
178+ { "violates an exclusion constraint" ,
179+ [ constraint: :exclusion , constraint_name: "cannot_overlap" ] }
180+ ]
181+
75182 assert changeset . data . __meta__ . state == :built
76183 end
77184
78185 test "check constraint" do
186+ num = @ base_migration + System . unique_integer ( [ :positive ] )
187+
188+ ExUnit.CaptureLog . capture_log ( fn ->
189+ :ok = up ( PoolRepo , num , CheckConstraintMigration , log: false )
190+ end )
191+
79192 # When the changeset doesn't expect the db error
80193 changeset = Ecto.Changeset . change ( % Constraint { } , price: - 10 )
194+
81195 exception =
82- assert_raise Ecto.ConstraintError , ~r / constraint error when attempting to insert struct / , fn ->
83- PoolRepo . insert ( changeset )
84- end
196+ assert_raise Ecto.ConstraintError ,
197+ ~r / constraint error when attempting to insert struct / ,
198+ fn -> PoolRepo . insert ( changeset ) end
85199
86200 assert exception . message =~ "\" positive_price\" (check_constraint)"
87201 assert exception . message =~ "The changeset has not defined any constraint."
@@ -92,24 +206,128 @@ defmodule Ecto.Integration.ConstraintsTest do
92206 changeset
93207 |> Ecto.Changeset . check_constraint ( :price , name: :positive_price )
94208 |> PoolRepo . insert ( )
95- assert changeset . errors == [ price: { "is invalid" , [ constraint: :check , constraint_name: "positive_price" ] } ]
209+
210+ assert changeset . errors == [
211+ price: { "is invalid" , [ constraint: :check , constraint_name: "positive_price" ] }
212+ ]
213+
96214 assert changeset . data . __meta__ . state == :built
97215
98216 # When the changeset does expect the db error and gives a custom message
99217 changeset = Ecto.Changeset . change ( % Constraint { } , price: - 10 )
218+
100219 { :error , changeset } =
101220 changeset
102- |> Ecto.Changeset . check_constraint ( :price , name: :positive_price , message: "price must be greater than 0" )
221+ |> Ecto.Changeset . check_constraint ( :price ,
222+ name: :positive_price ,
223+ message: "price must be greater than 0"
224+ )
103225 |> PoolRepo . insert ( )
104- assert changeset . errors == [ price: { "price must be greater than 0" , [ constraint: :check , constraint_name: "positive_price" ] } ]
226+
227+ assert changeset . errors == [
228+ price:
229+ { "price must be greater than 0" ,
230+ [ constraint: :check , constraint_name: "positive_price" ] }
231+ ]
232+
105233 assert changeset . data . __meta__ . state == :built
106234
107235 # When the change does not violate the check constraint
108236 changeset = Ecto.Changeset . change ( % Constraint { } , price: 10 , from: 100 , to: 200 )
237+
109238 { :ok , changeset } =
110239 changeset
111- |> Ecto.Changeset . check_constraint ( :price , name: :positive_price , message: "price must be greater than 0" )
240+ |> Ecto.Changeset . check_constraint ( :price ,
241+ name: :positive_price ,
242+ message: "price must be greater than 0"
243+ )
112244 |> PoolRepo . insert ( )
245+
113246 assert is_integer ( changeset . id )
114247 end
248+
249+ @ tag :constraint_handler
250+ test "custom handled constraint" do
251+ num = @ base_migration + System . unique_integer ( [ :positive ] )
252+
253+ ExUnit.CaptureLog . capture_log ( fn ->
254+ :ok = up ( PoolRepo , num , TriggerEmulatingConstraintMigration , log: false )
255+ end )
256+
257+ changeset = Ecto.Changeset . change ( % Constraint { } , price: 99 , from: 201 , to: 202 )
258+ { :ok , item } = PoolRepo . insert ( changeset )
259+
260+ above_max_changeset = Ecto.Changeset . change ( % Constraint { } , price: 100 )
261+
262+ msg_re = ~r/ constraint error when attempting to insert struct/
263+
264+ # When the changeset doesn't expect the db error
265+ exception =
266+ assert_raise Ecto.ConstraintError , msg_re , fn -> PoolRepo . insert ( above_max_changeset ) end
267+
268+ assert exception . message =~ "\" price_above_max\" (check_constraint)"
269+ assert exception . message =~ "The changeset has not defined any constraint."
270+ assert exception . message =~ "call `check_constraint/3`"
271+
272+ # When the changeset does expect the db error, but doesn't give a custom message
273+ { :error , changeset } =
274+ above_max_changeset
275+ |> Ecto.Changeset . check_constraint ( :price , name: :price_above_max )
276+ |> PoolRepo . insert ( )
277+
278+ assert changeset . errors == [
279+ price: { "is invalid" , [ constraint: :check , constraint_name: "price_above_max" ] }
280+ ]
281+
282+ assert changeset . data . __meta__ . state == :built
283+
284+ # When the changeset does expect the db error and gives a custom message
285+ { :error , changeset } =
286+ above_max_changeset
287+ |> Ecto.Changeset . check_constraint ( :price ,
288+ name: :price_above_max ,
289+ message: "must be less than the max price"
290+ )
291+ |> PoolRepo . insert ( )
292+
293+ assert changeset . errors == [
294+ price:
295+ { "must be less than the max price" ,
296+ [ constraint: :check , constraint_name: "price_above_max" ] }
297+ ]
298+
299+ assert changeset . data . __meta__ . state == :built
300+
301+ # When the changeset does expect the db error, but a different handler is used
302+ exception =
303+ assert_raise Postgrex.Error , fn ->
304+ above_max_changeset
305+ |> Ecto.Changeset . check_constraint ( :price , name: :price_above_max )
306+ |> PoolRepo . insert (
307+ constraint_handler: { Ecto.Adapters.Postgres.Connection , :to_constraints , [ ] }
308+ )
309+ end
310+
311+ # Just raises as-is
312+ assert exception . postgres . message == "price must be less than 100, got 100"
313+
314+ # When custom error is coming from an UPDATE
315+ above_max_update_changeset = Ecto.Changeset . change ( item , price: 100 )
316+
317+ { :error , changeset } =
318+ above_max_update_changeset
319+ |> Ecto.Changeset . check_constraint ( :price ,
320+ name: :price_above_max ,
321+ message: "must be less than the max price"
322+ )
323+ |> PoolRepo . insert ( )
324+
325+ assert changeset . errors == [
326+ price:
327+ { "must be less than the max price" ,
328+ [ constraint: :check , constraint_name: "price_above_max" ] }
329+ ]
330+
331+ assert changeset . data . __meta__ . state == :loaded
332+ end
115333end
0 commit comments