Skip to content

Commit b2c9c98

Browse files
authored
add check constraints for MySQL (#621)
1 parent ccb62ea commit b2c9c98

File tree

7 files changed

+223
-40
lines changed

7 files changed

+223
-40
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
defmodule Ecto.Integration.ConstraintsTest do
2+
use ExUnit.Case, async: true
3+
4+
import Ecto.Migrator, only: [up: 4]
5+
alias Ecto.Integration.PoolRepo
6+
7+
defmodule ConstraintMigration do
8+
use Ecto.Migration
9+
10+
@table table(:constraints_test)
11+
12+
def change do
13+
create @table do
14+
add :price, :integer
15+
add :from, :integer
16+
add :to, :integer
17+
end
18+
19+
# Only valid after MySQL 8.0.19
20+
create constraint(@table.name, :positive_price, check: "price > 0")
21+
end
22+
end
23+
24+
defmodule Constraint do
25+
use Ecto.Integration.Schema
26+
27+
schema "constraints_test" do
28+
field :price, :integer
29+
field :from, :integer
30+
field :to, :integer
31+
end
32+
end
33+
34+
@base_migration 2_000_000
35+
36+
setup_all do
37+
ExUnit.CaptureLog.capture_log(fn ->
38+
num = @base_migration + System.unique_integer([:positive])
39+
up(PoolRepo, num, ConstraintMigration, log: false)
40+
end)
41+
42+
:ok
43+
end
44+
45+
@tag :create_constraint
46+
test "check constraint" do
47+
# When the changeset doesn't expect the db error
48+
changeset = Ecto.Changeset.change(%Constraint{}, price: -10)
49+
exception =
50+
assert_raise Ecto.ConstraintError, ~r/constraint error when attempting to insert struct/, fn ->
51+
PoolRepo.insert(changeset)
52+
end
53+
54+
assert exception.message =~ "\"positive_price\" (check_constraint)"
55+
assert exception.message =~ "The changeset has not defined any constraint."
56+
assert exception.message =~ "call `check_constraint/3`"
57+
58+
# When the changeset does expect the db error, but doesn't give a custom message
59+
{:error, changeset} =
60+
changeset
61+
|> Ecto.Changeset.check_constraint(:price, name: :positive_price)
62+
|> PoolRepo.insert()
63+
assert changeset.errors == [price: {"is invalid", [constraint: :check, constraint_name: "positive_price"]}]
64+
assert changeset.data.__meta__.state == :built
65+
66+
# When the changeset does expect the db error and gives a custom message
67+
changeset = Ecto.Changeset.change(%Constraint{}, price: -10)
68+
{:error, changeset} =
69+
changeset
70+
|> Ecto.Changeset.check_constraint(:price, name: :positive_price, message: "price must be greater than 0")
71+
|> PoolRepo.insert()
72+
assert changeset.errors == [price: {"price must be greater than 0", [constraint: :check, constraint_name: "positive_price"]}]
73+
assert changeset.data.__meta__.state == :built
74+
75+
# When the change does not violate the check constraint
76+
changeset = Ecto.Changeset.change(%Constraint{}, price: 10, from: 100, to: 200)
77+
{:ok, changeset} =
78+
changeset
79+
|> Ecto.Changeset.check_constraint(:price, name: :positive_price, message: "price must be greater than 0")
80+
|> PoolRepo.insert()
81+
assert is_integer(changeset.id)
82+
end
83+
end

integration_test/myxql/test_helper.exs

+1-1
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ excludes = [
121121
if Version.match?(version, ">= 8.0.0") do
122122
ExUnit.configure(exclude: excludes)
123123
else
124-
ExUnit.configure(exclude: [:values_list, :rename_column | excludes])
124+
ExUnit.configure(exclude: [:create_constraint, :values_list, :rename_column | excludes])
125125
end
126126

127127
:ok = Ecto.Migrator.up(TestRepo, 0, Ecto.Integration.Migration, log: false)

lib/ecto/adapters/myxql/connection.ex

+51-8
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ if Code.ensure_loaded?(MyXQL) do
7878
end
7979
end
8080

81+
def to_constraints(
82+
%MyXQL.Error{mysql: %{name: :ER_CHECK_CONSTRAINT_VIOLATED}, message: message},
83+
_opts
84+
) do
85+
with [_, quoted] <- :binary.split(message, ["Check constraint "]),
86+
[_, constraint | _] <- :binary.split(quoted, @quotes, [:global]) do
87+
[check: constraint]
88+
else
89+
_ -> []
90+
end
91+
end
92+
8193
def to_constraints(_, _),
8294
do: []
8395

@@ -1026,12 +1038,31 @@ if Code.ensure_loaded?(MyXQL) do
10261038
def execute_ddl({:create_if_not_exists, %Index{}}),
10271039
do: error!(nil, "MySQL adapter does not support create if not exists for index")
10281040

1029-
def execute_ddl({:create, %Constraint{check: check}}) when is_binary(check),
1030-
do: error!(nil, "MySQL adapter does not support check constraints")
1041+
def execute_ddl({:create, %Constraint{check: check} = constraint}) when is_binary(check) do
1042+
table_name = quote_name(constraint.prefix, constraint.table)
1043+
[["ALTER TABLE ", table_name, " ADD ", new_constraint_expr(constraint)]]
1044+
end
10311045

10321046
def execute_ddl({:create, %Constraint{exclude: exclude}}) when is_binary(exclude),
10331047
do: error!(nil, "MySQL adapter does not support exclusion constraints")
10341048

1049+
def execute_ddl({:drop, %Constraint{}, :cascade}),
1050+
do: error!(nil, "MySQL does not support `CASCADE` in `DROP CONSTRAINT` commands")
1051+
1052+
def execute_ddl({:drop, %Constraint{} = constraint, _}) do
1053+
[
1054+
[
1055+
"ALTER TABLE ",
1056+
quote_name(constraint.prefix, constraint.table),
1057+
" DROP CONSTRAINT ",
1058+
quote_name(constraint.name)
1059+
]
1060+
]
1061+
end
1062+
1063+
def execute_ddl({:drop_if_exists, %Constraint{}, _}),
1064+
do: error!(nil, "MySQL adapter does not support `drop_if_exists` for constraints")
1065+
10351066
def execute_ddl({:drop, %Index{}, :cascade}),
10361067
do: error!(nil, "MySQL adapter does not support cascade in drop index")
10371068

@@ -1047,12 +1078,6 @@ if Code.ensure_loaded?(MyXQL) do
10471078
]
10481079
end
10491080

1050-
def execute_ddl({:drop, %Constraint{}, _}),
1051-
do: error!(nil, "MySQL adapter does not support constraints")
1052-
1053-
def execute_ddl({:drop_if_exists, %Constraint{}, _}),
1054-
do: error!(nil, "MySQL adapter does not support constraints")
1055-
10561081
def execute_ddl({:drop_if_exists, %Index{}, _}),
10571082
do: error!(nil, "MySQL adapter does not support drop if exists for index")
10581083

@@ -1244,6 +1269,17 @@ if Code.ensure_loaded?(MyXQL) do
12441269
defp null_expr(true), do: " NULL"
12451270
defp null_expr(_), do: []
12461271

1272+
defp new_constraint_expr(%Constraint{check: check} = constraint) when is_binary(check) do
1273+
[
1274+
"CONSTRAINT ",
1275+
quote_name(constraint.name),
1276+
" CHECK (",
1277+
check,
1278+
")",
1279+
validate(constraint.validate)
1280+
]
1281+
end
1282+
12471283
defp default_expr({:ok, nil}),
12481284
do: " DEFAULT NULL"
12491285

@@ -1401,6 +1437,9 @@ if Code.ensure_loaded?(MyXQL) do
14011437
defp reference_on_update(:restrict), do: " ON UPDATE RESTRICT"
14021438
defp reference_on_update(_), do: []
14031439

1440+
defp validate(false), do: " NOT ENFORCED"
1441+
defp validate(_), do: []
1442+
14041443
## Helpers
14051444

14061445
defp get_source(query, sources, ix, source) do
@@ -1423,6 +1462,10 @@ if Code.ensure_loaded?(MyXQL) do
14231462

14241463
defp maybe_add_column_names(_, name), do: name
14251464

1465+
defp quote_name(nil, name), do: quote_name(name)
1466+
1467+
defp quote_name(prefix, name), do: [quote_name(prefix), ?., quote_name(name)]
1468+
14261469
defp quote_name(name) when is_atom(name) do
14271470
quote_name(Atom.to_string(name))
14281471
end

lib/ecto/migration.ex

+26-3
Original file line numberDiff line numberDiff line change
@@ -1491,11 +1491,34 @@ defmodule Ecto.Migration do
14911491
* `:check` - A check constraint expression. Required when creating a check constraint.
14921492
* `:exclude` - An exclusion constraint expression. Required when creating an exclusion constraint.
14931493
* `:prefix` - The prefix for the table.
1494-
* `:validate` - Whether or not to validate the constraint on creation (true by default). Only
1495-
available in PostgreSQL, and should be followed by a command to validate the new constraint in
1496-
a following migration if false.
1494+
* `:validate` - Whether or not to validate the constraint on creation (true by default). See the section below for more information
14971495
* `:comment` - adds a comment to the constraint.
14981496
1497+
1498+
## Using `validate: false`
1499+
1500+
Validation/Enforcement of a constraint is enabled by default, but disabling on constraint
1501+
creation is supported by PostgreSQL, and MySQL, and can be done by setting `validate: false`.
1502+
1503+
Setting `validate: false` as an option can be useful, as the creation of a constraint will cause
1504+
a full table scan to check existing rows. The constraint will still be enforced for subsequent
1505+
inserts and updates, but should then be updated in a following command or migration to enforce
1506+
the new constraint.
1507+
1508+
Validating / Enforcing the constraint in a later command, or migration, can be done like so:
1509+
1510+
```
1511+
def change do
1512+
# PostgreSQL
1513+
  execute "ALTER TABLE products VALIDATE CONSTRAINT price_must_be_positive", ""
1514+
1515+
# MySQL
1516+
  execute "ALTER TABLE products ALTER CONSTRAINT price_must_be_positive ENFORCED", ""
1517+
end
1518+
```
1519+
1520+
See the [Safe Ecto Migrations guide](https://fly.io/phoenix-files/safe-ecto-migrations/) for an
1521+
in-depth explanation of the benefits of this approach.
14991522
"""
15001523
def constraint(table, name, opts \\ [])
15011524

mix.exs

+1-1
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ defmodule EctoSQL.MixProject do
9292
if path = System.get_env("MYXQL_PATH") do
9393
{:myxql, path: path}
9494
else
95-
{:myxql, "~> 0.6", optional: true}
95+
{:myxql, "~> 0.7", optional: true}
9696
end
9797
end
9898

mix.lock

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"},
1111
"makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"},
1212
"makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"},
13-
"myxql": {:hex, :myxql, "0.6.3", "3d77683a09f1227abb8b73d66b275262235c5cae68182f0cfa5897d72a03700e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:geo, "~> 3.4", [hex: :geo, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "af9eb517ddaced5c5c28e8749015493757fd4413f2cfccea449c466d405d9f51"},
13+
"myxql": {:hex, :myxql, "0.7.1", "7c7b75aa82227cd2bc9b7fbd4de774fb19a1cdb309c219f411f82ca8860f8e01", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:geo, "~> 3.4", [hex: :geo, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a491cdff53353a09b5850ac2d472816ebe19f76c30b0d36a43317a67c9004936"},
1414
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
1515
"postgrex": {:hex, :postgrex, "0.17.3", "c92cda8de2033a7585dae8c61b1d420a1a1322421df84da9a82a6764580c503d", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "946cf46935a4fdca7a81448be76ba3503cff082df42c6ec1ff16a4bdfbfb098d"},
1616
"statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"},

test/ecto/adapters/myxql_test.exs

+60-26
Original file line numberDiff line numberDiff line change
@@ -1621,7 +1621,8 @@ defmodule Ecto.Adapters.MyXQLTest do
16211621

16221622
# DDL
16231623

1624-
import Ecto.Migration, only: [table: 1, table: 2, index: 2, index: 3, constraint: 3]
1624+
import Ecto.Migration,
1625+
only: [table: 1, table: 2, index: 2, index: 3, constraint: 2, constraint: 3]
16251626

16261627
test "executing a string during migration" do
16271628
assert execute_ddl("example") == ["example"]
@@ -1963,23 +1964,6 @@ defmodule Ecto.Adapters.MyXQLTest do
19631964
assert execute_ddl(drop) == [~s|DROP TABLE `foo`.`posts`|]
19641965
end
19651966

1966-
test "drop constraint" do
1967-
assert_raise ArgumentError, ~r/MySQL adapter does not support constraints/, fn ->
1968-
execute_ddl(
1969-
{:drop, constraint(:products, "price_must_be_positive", prefix: "foo"), :restrict}
1970-
)
1971-
end
1972-
end
1973-
1974-
test "drop_if_exists constraint" do
1975-
assert_raise ArgumentError, ~r/MySQL adapter does not support constraints/, fn ->
1976-
execute_ddl(
1977-
{:drop_if_exists, constraint(:products, "price_must_be_positive", prefix: "foo"),
1978-
:restrict}
1979-
)
1980-
end
1981-
end
1982-
19831967
test "alter table" do
19841968
alter =
19851969
{:alter, table(:posts),
@@ -2152,15 +2136,34 @@ defmodule Ecto.Adapters.MyXQLTest do
21522136
end
21532137

21542138
test "create constraints" do
2155-
assert_raise ArgumentError, "MySQL adapter does not support check constraints", fn ->
2156-
create = {:create, constraint(:products, "foo", check: "price")}
2157-
assert execute_ddl(create)
2158-
end
2139+
create = {:create, constraint(:products, "price_must_be_positive", check: "price > 0")}
21592140

2160-
assert_raise ArgumentError, "MySQL adapter does not support check constraints", fn ->
2161-
create = {:create, constraint(:products, "foo", check: "price", validate: false)}
2162-
assert execute_ddl(create)
2163-
end
2141+
assert execute_ddl(create) ==
2142+
[
2143+
~s|ALTER TABLE `products` ADD CONSTRAINT `price_must_be_positive` CHECK (price > 0)|
2144+
]
2145+
2146+
create =
2147+
{:create,
2148+
constraint(:products, "price_must_be_positive", check: "price > 0", prefix: "foo")}
2149+
2150+
assert execute_ddl(create) ==
2151+
[
2152+
~s|ALTER TABLE `foo`.`products` ADD CONSTRAINT `price_must_be_positive` CHECK (price > 0)|
2153+
]
2154+
2155+
create =
2156+
{:create,
2157+
constraint(:products, "price_must_be_positive",
2158+
check: "price > 0",
2159+
prefix: "foo",
2160+
validate: false
2161+
)}
2162+
2163+
assert execute_ddl(create) ==
2164+
[
2165+
~s|ALTER TABLE `foo`.`products` ADD CONSTRAINT `price_must_be_positive` CHECK (price > 0) NOT ENFORCED|
2166+
]
21642167

21652168
assert_raise ArgumentError, "MySQL adapter does not support exclusion constraints", fn ->
21662169
create = {:create, constraint(:products, "bar", exclude: "price")}
@@ -2173,6 +2176,37 @@ defmodule Ecto.Adapters.MyXQLTest do
21732176
end
21742177
end
21752178

2179+
test "drop constraint" do
2180+
drop = {:drop, constraint(:products, "price_must_be_positive"), :restrict}
2181+
2182+
assert execute_ddl(drop) ==
2183+
[~s|ALTER TABLE `products` DROP CONSTRAINT `price_must_be_positive`|]
2184+
2185+
drop = {:drop, constraint(:products, "price_must_be_positive", prefix: "foo"), :restrict}
2186+
2187+
assert execute_ddl(drop) ==
2188+
[~s|ALTER TABLE `foo`.`products` DROP CONSTRAINT `price_must_be_positive`|]
2189+
2190+
drop_cascade = {:drop, constraint(:products, "price_must_be_positive"), :cascade}
2191+
2192+
assert_raise ArgumentError,
2193+
~r/MySQL does not support `CASCADE` in `DROP CONSTRAINT` commands/,
2194+
fn ->
2195+
execute_ddl(drop_cascade)
2196+
end
2197+
end
2198+
2199+
test "drop_if_exists constraint" do
2200+
assert_raise ArgumentError,
2201+
~r/MySQL adapter does not support `drop_if_exists` for constraints/,
2202+
fn ->
2203+
execute_ddl(
2204+
{:drop_if_exists,
2205+
constraint(:products, "price_must_be_positive", prefix: "foo"), :restrict}
2206+
)
2207+
end
2208+
end
2209+
21762210
test "create an index using a different type" do
21772211
create = {:create, index(:posts, [:permalink], using: :hash)}
21782212

0 commit comments

Comments
 (0)