Skip to content

Commit d9918f3

Browse files
committed
test postgres custom constraint handlers
1 parent 4b41c2e commit d9918f3

File tree

2 files changed

+246
-15
lines changed

2 files changed

+246
-15
lines changed

integration_test/pg/constraints_test.exs

Lines changed: 232 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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
115333
end

integration_test/pg/test_helper.exs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,16 @@ pool_repo_config = [
6767
max_seconds: 10
6868
]
6969

70-
Application.put_env(:ecto_sql, PoolRepo, pool_repo_config)
70+
Application.put_env(
71+
:ecto_sql,
72+
PoolRepo,
73+
pool_repo_config ++
74+
[
75+
# Passes through into adapter_meta
76+
constraint_handler:
77+
{Ecto.Integration.ConstraintsTest.CustomConstraintHandler, :to_constraints, []}
78+
]
79+
)
7180

7281
Application.put_env(
7382
:ecto_sql,
@@ -99,7 +108,11 @@ _ = Ecto.Adapters.Postgres.storage_down(TestRepo.config())
99108
:ok = Ecto.Adapters.Postgres.storage_up(TestRepo.config())
100109

101110
{:ok, _pid} = TestRepo.start_link()
111+
112+
# Passes through into adapter_meta, overrides Application config
113+
# {:ok, _pid} = PoolRepo.start_link([constraint_handler: {Ecto.Integration.ConstraintsTest.CustomConstraintHandler, :to_constraints, []}])
102114
{:ok, _pid} = PoolRepo.start_link()
115+
103116
{:ok, _pid} = AdvisoryLockPoolRepo.start_link()
104117

105118
%{rows: [[version]]} = TestRepo.query!("SHOW server_version", [])

0 commit comments

Comments
 (0)