Skip to content

Commit abc947d

Browse files
committed
Support table-level check constraints
1 parent 60407b7 commit abc947d

4 files changed

Lines changed: 130 additions & 23 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ project adheres to [Semantic Versioning][semver].
77

88
## Unreleased
99

10+
- added: Support check constraints via `Ecto.Migration.constraint/3`.
1011
- changed: Reimplement querying prefix names.
1112
- added: Raise on cross-database foreign keys.
1213
- changed: Made `quote_name/1` private.

‎lib/ecto/adapters/sqlite3.ex‎

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -160,22 +160,21 @@ defmodule Ecto.Adapters.SQLite3 do
160160
### Check constraints
161161
162162
SQLite3 supports specifying check constraints on the table or on the column definition.
163-
We currently only support adding a check constraint via a column definition, since the
164-
table definition approach only works at table-creation time and cannot be added at
165-
table-alter time. You can see more information in the SQLite3
166-
[CREATE TABLE documentation](https://sqlite.org/lang_createtable.html).
163+
Table-alter time check constraints are supported since SQLite 3.53.0.
167164
168-
Because of this, you cannot add a constraint via the normal `Ecto.Migration.constraint/3`
169-
method, as that operates via `ALTER TABLE ADD CONSTRAINT`, and this type of `ALTER TABLE`
170-
operation SQLite3 does not support. You can however get the full functionality by
171-
adding a constraint at the column level, specifying the name and expression. Per the
172-
SQLite3 documentation, there is no _functional_ difference between a column or table
173-
constraint.
165+
Adding a constraint via `Ecto.Migration.constraint/3` is recommended for new projects.
166+
Note that SQLite's `ADD CONSTRAINTS` verifies existing rows with `WHERE (check) IS NOT TRUE`,
167+
so make sure your check is NULL-safe.
174168
175-
Thus, adding a check constraint for a new column is as simple as:
169+
As an adapter-specific extension, the old way of adding column-level constraints is still
170+
available:
176171
177172
add :email, :string, check: %{name: "test_constraint", expr: "email != 'test@example.com'"}
178173
174+
Note that column-level constraints are slightly different than table-level constraints,
175+
since `DROP COLUMN` can drop column-level constraints but not table-level constraints.
176+
Drop the constraint first before dropping the column in those cases.
177+
179178
### Handling foreign key constraints in changesets
180179
181180
Unfortunately, unlike other databases, SQLite3 does not provide the precise name of

‎lib/ecto/adapters/sqlite3/connection.ex‎

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -584,16 +584,40 @@ defmodule Ecto.Adapters.SQLite3.Connection do
584584
raise ArgumentError, "SQLite3 adapter does not support keyword lists in execute"
585585
end
586586

587-
def execute_ddl({:create, %Constraint{}}) do
588-
raise ArgumentError, "SQLite3 does not support ALTER TABLE ADD CONSTRAINT."
587+
def execute_ddl({:create, %Constraint{check: check} = constraint})
588+
when is_binary(check) do
589+
[
590+
[
591+
"ALTER TABLE ",
592+
quote_name(constraint.prefix, constraint.table),
593+
" ADD",
594+
new_constraint_expr(constraint)
595+
]
596+
]
597+
end
598+
599+
def execute_ddl({:create, %Constraint{exclude: exclude}}) when is_binary(exclude) do
600+
raise ArgumentError, "SQLite3 does not support exclusion constraints"
601+
end
602+
603+
def execute_ddl({:drop, %Constraint{}, :cascade}) do
604+
raise ArgumentError,
605+
"SQLite3 does not support `CASCADE` in `DROP CONSTRAINT` commands"
589606
end
590607

591-
def execute_ddl({:drop, %Constraint{}, _mode}) do
592-
raise ArgumentError, "SQLite3 does not support ALTER TABLE DROP CONSTRAINT."
608+
def execute_ddl({:drop, %Constraint{} = constraint, _mode}) do
609+
[
610+
[
611+
"ALTER TABLE ",
612+
quote_name(constraint.prefix, constraint.table),
613+
" DROP CONSTRAINT ",
614+
quote_name(constraint.name)
615+
]
616+
]
593617
end
594618

595619
def execute_ddl({:drop_if_exists, %Constraint{}, _mode}) do
596-
raise ArgumentError, "SQLite3 does not support ALTER TABLE DROP CONSTRAINT."
620+
raise ArgumentError, "SQLite3 does not support `drop_if_exists` for constraints"
597621
end
598622

599623
def execute_ddl({:rename, %Index{} = index, new_index}) do
@@ -1671,6 +1695,13 @@ defmodule Ecto.Adapters.SQLite3.Connection do
16711695
defp null_expr(true), do: " NULL"
16721696
defp null_expr(_), do: []
16731697

1698+
defp new_constraint_expr(%Constraint{validate: validate}) when validate == false do
1699+
raise ArgumentError, "SQLite3 does not support `validate: false` in constraints"
1700+
end
1701+
1702+
defp new_constraint_expr(%Constraint{check: check} = constraint)
1703+
when is_binary(check), do: check_expr(%{name: constraint.name, expr: check})
1704+
16741705
defp default_expr({:ok, nil}) do
16751706
" DEFAULT NULL"
16761707
end

‎test/ecto/adapters/sqlite3/connection/migration_test.exs‎

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ defmodule Ecto.Adapters.SQLite3.Connection.MigrationTest do
22
use ExUnit.Case, async: true
33

44
import Ecto.Adapters.SQLite3.TestHelpers
5-
import Ecto.Migration, only: [table: 1, table: 2, index: 2, index: 3, constraint: 3]
5+
6+
import Ecto.Migration,
7+
only: [table: 1, table: 2, index: 2, index: 3, constraint: 2, constraint: 3]
68

79
alias Ecto.Migration.Reference
810

@@ -512,21 +514,95 @@ defmodule Ecto.Adapters.SQLite3.Connection.MigrationTest do
512514
end
513515
end
514516

515-
test "drop constraint" do
517+
test "create check constraint" do
518+
create =
519+
{:create, constraint(:products, "price_must_be_positive", check: "price > 0")}
520+
521+
assert execute_ddl(create) ==
522+
[
523+
~s|ALTER TABLE "products" ADD CONSTRAINT "price_must_be_positive" CHECK (price > 0)|
524+
]
525+
526+
create =
527+
{:create,
528+
constraint(:products, "price_must_be_positive",
529+
check: "price > 0",
530+
prefix: "foo"
531+
)}
532+
533+
assert execute_ddl(create) ==
534+
[
535+
~s|ALTER TABLE "foo"."products" ADD CONSTRAINT "price_must_be_positive" CHECK (price > 0)|
536+
]
537+
end
538+
539+
test "create exclusion constraint" do
540+
assert_raise ArgumentError, "SQLite3 does not support exclusion constraints", fn ->
541+
execute_ddl(
542+
{:create,
543+
constraint(:products, "price_must_be_positive",
544+
exclude: ~s|gist (int4range("from", "to", '[]') WITH &&)|
545+
)}
546+
)
547+
end
548+
end
549+
550+
test "create constraint with comment" do
551+
create =
552+
{:create,
553+
constraint(:products, "price_must_be_positive",
554+
check: "price > 0",
555+
prefix: "foo",
556+
comment: "comment"
557+
)}
558+
559+
assert execute_ddl(create) == [
560+
~s|ALTER TABLE "foo"."products" ADD CONSTRAINT "price_must_be_positive" CHECK (price > 0)|
561+
]
562+
563+
# NOTE: Comments are not supported by SQLite. DDL query generator will ignore them.
564+
end
565+
566+
test "create invalid constraint" do
516567
assert_raise ArgumentError,
517-
~r/SQLite3 does not support ALTER TABLE DROP CONSTRAINT./,
568+
"SQLite3 does not support `validate: false` in constraints",
518569
fn ->
519570
execute_ddl(
520-
{:drop,
521-
constraint(:products, "price_must_be_positive", prefix: :foo),
522-
:restrict}
571+
{:create,
572+
constraint(:products, "price_must_be_positive",
573+
check: "price > 0",
574+
prefix: "foo",
575+
validate: false
576+
)}
523577
)
524578
end
525579
end
526580

581+
test "drop constraint" do
582+
drop =
583+
{:drop, constraint(:products, "price_must_be_positive"), :restrict}
584+
585+
assert execute_ddl(drop) == [
586+
~s|ALTER TABLE "products" DROP CONSTRAINT "price_must_be_positive"|
587+
]
588+
589+
drop = {:drop, constraint(:products, "price_must_be_positive"), :cascade}
590+
591+
assert_raise ArgumentError,
592+
"SQLite3 does not support `CASCADE` in `DROP CONSTRAINT` commands",
593+
fn -> execute_ddl(drop) end
594+
595+
drop =
596+
{:drop, constraint(:products, "price_must_be_positive", prefix: :foo), :restrict}
597+
598+
assert execute_ddl(drop) == [
599+
~s|ALTER TABLE "foo"."products" DROP CONSTRAINT "price_must_be_positive"|
600+
]
601+
end
602+
527603
test "drop_if_exists constraint" do
528604
assert_raise ArgumentError,
529-
~r/SQLite3 does not support ALTER TABLE DROP CONSTRAINT./,
605+
"SQLite3 does not support `drop_if_exists` for constraints",
530606
fn ->
531607
execute_ddl(
532608
{:drop_if_exists,

0 commit comments

Comments
 (0)