Permalink
Browse files

Better support for check and exclusion constraints

This commit adds:

- Migration support for both check and exclusion constraints, including
  the ability to roll them back
- Changeset support for check constraints

Specifically, this supports using `create constraint(...)` in a `change` migration, and `check_constraint(...)` in a changeset function.
  • Loading branch information...
1 parent dc0ab7d commit 6d9de1ffbfe42f0430fcd2a56b997bada353744b @nathanl nathanl committed Dec 31, 2015
@@ -0,0 +1,69 @@
+defmodule Ecto.Integration.CheckConstraintTest do
+ use ExUnit.Case, async: true
+
+ alias Ecto.Integration.TestRepo
+ import Ecto.Migrator, only: [up: 4, down: 4]
+
+ defmodule CheckConstraintMigration do
+ use Ecto.Migration
+
+ @table table(:products)
+
+ def change do
+ create @table do
+ add :price, :integer
+ end
+ create constraint(@table.name, "positive_price", check: "price > 0")
+ end
+ end
+
+ defmodule CheckConstraintModel do
+ use Ecto.Integration.Schema
+
+ schema "products" do
+ field :price, :integer
+ end
+ end
+
+ test "creating, using, and dropping a check constraint" do
+ assert :ok == up(TestRepo, 20120806000000, CheckConstraintMigration, log: false)
+
+ # When the changeset doesn't expect the db error
+ changeset = Ecto.Changeset.change(%CheckConstraintModel{}, price: -10)
+ exception =
+ assert_raise(Ecto.ConstraintError, ~r/constraint error when attempting to insert model/, fn ->
+ TestRepo.insert(changeset)
+ end
+ )
+ assert exception.message =~ "check: positive_price"
+ assert exception.message =~ "The changeset has not defined any constraint."
+
+ # When the changeset does expect the db error, but doesn't give a custom message
+ changeset = Ecto.Changeset.change(%CheckConstraintModel{}, price: -10)
+ {:error, changeset} =
+ changeset
+ |> Ecto.Changeset.check_constraint(:price, name: :positive_price)
+ |> TestRepo.insert()
+ assert changeset.errors == [price: "violates check 'positive_price'"]
+ assert changeset.model.__meta__.state == :built
+
+ # When the changeset does expect the db error and gives a custom message
+ changeset = Ecto.Changeset.change(%CheckConstraintModel{}, price: -10)
+ {:error, changeset} =
+ changeset
+ |> Ecto.Changeset.check_constraint(:price, name: :positive_price, message: "price must be greater than 0")
+ |> TestRepo.insert()
+ assert changeset.errors == [price: "price must be greater than 0"]
+ assert changeset.model.__meta__.state == :built
+
+ # When the change does not violate the check constraint
+ changeset = Ecto.Changeset.change(%CheckConstraintModel{}, price: 10)
+ {:ok, changeset} =
+ changeset
+ |> Ecto.Changeset.check_constraint(:price, name: :positive_price, message: "price must be greater than 0")
+ |> TestRepo.insert()
+ assert is_integer(changeset.id)
+
+ assert :ok == down(TestRepo, 20120806000000, CheckConstraintMigration, log: false)
+ end
+end
@@ -1,4 +1,4 @@
-defmodule Ecto.Integration.ConstraintTest do
+defmodule Ecto.Integration.ExclusionConstraintTest do
use ExUnit.Case, async: true
alias Ecto.Integration.TestRepo
@@ -7,61 +7,58 @@ defmodule Ecto.Integration.ConstraintTest do
defmodule ExcludeConstraintMigration do
use Ecto.Migration
- @table table(:exclude_constraint_migration)
+ @table table(:non_overlapping_ranges)
- def up do
+ def change do
create @table do
add :from, :integer
add :to, :integer
end
- execute "ALTER TABLE exclude_constraint_migration " <>
- "ADD CONSTRAINT overlapping_ranges EXCLUDE USING gist (int4range(\"from\", \"to\") WITH &&)"
- end
-
- def down do
- drop @table
+ create constraint(@table.name, :cannot_overlap, exclude: ~s|gist (int4range("from", "to", '[]') WITH &&)|)
end
end
defmodule ExcludeConstraintModel do
use Ecto.Integration.Schema
- schema "exclude_constraint_migration" do
+ schema "non_overlapping_ranges" do
field :from, :integer
field :to, :integer
end
end
- test "exclude constraint exception" do
+ test "creating, using, and dropping an exclude constraint" do
assert :ok == up(TestRepo, 20050906120000, ExcludeConstraintMigration, log: false)
- changeset = Ecto.Changeset.change(%ExcludeConstraintModel{}, from: 0, to: 1)
+ changeset = Ecto.Changeset.change(%ExcludeConstraintModel{}, from: 0, to: 10)
{:ok, _} = TestRepo.insert(changeset)
+ non_overlapping_changeset = Ecto.Changeset.change(%ExcludeConstraintModel{}, from: 11, to: 12)
+ {:ok, _} = TestRepo.insert(non_overlapping_changeset)
+
+ overlapping_changeset = Ecto.Changeset.change(%ExcludeConstraintModel{}, from: 9, to: 12)
+
exception =
assert_raise Ecto.ConstraintError, ~r/constraint error when attempting to insert model/, fn ->
- changeset
+ overlapping_changeset
|> TestRepo.insert()
end
-
- assert exception.message =~ "exclude: overlapping_ranges"
+ assert exception.message =~ "exclude: cannot_overlap"
assert exception.message =~ "The changeset has not defined any constraint."
message = ~r/constraint error when attempting to insert model/
exception =
assert_raise Ecto.ConstraintError, message, fn ->
- changeset
+ overlapping_changeset
|> Ecto.Changeset.exclude_constraint(:from)
|> TestRepo.insert()
end
-
- assert exception.message =~ "exclude: overlapping_ranges"
+ assert exception.message =~ "exclude: cannot_overlap"
{:error, changeset} =
- changeset
- |> Ecto.Changeset.exclude_constraint(:from, name: :overlapping_ranges)
+ overlapping_changeset
+ |> Ecto.Changeset.exclude_constraint(:from, name: :cannot_overlap)
|> TestRepo.insert()
-
assert changeset.errors == [from: "violates an exclusion constraint"]
assert changeset.model.__meta__.state == :built
@@ -31,6 +31,8 @@ if Code.ensure_loaded?(Postgrex.Connection) do
do: [foreign_key: constraint]
def to_constraints(%Postgrex.Error{postgres: %{code: :exclusion_violation, constraint: constraint}}),
do: [exclude: constraint]
+ def to_constraints(%Postgrex.Error{postgres: %{code: :check_violation, constraint: constraint}}),
+ do: [check: constraint]
# Postgres 9.2 and earlier does not provide the constraint field
def to_constraints(%Postgrex.Error{postgres: %{code: :unique_violation, message: message}}) do
@@ -51,6 +53,12 @@ if Code.ensure_loaded?(Postgrex.Connection) do
_ -> []
end
end
+ def to_constraints(%Postgrex.Error{postgres: %{code: :check_violation, message: message}}) do
+ case :binary.split(message, " check constraint ") do
+ [_, quoted] -> [check: strip_quotes(quoted)]
+ _ -> []
+ end
+ end
def to_constraints(%Postgrex.Error{}),
do: []
@@ -547,6 +555,7 @@ if Code.ensure_loaded?(Postgrex.Connection) do
alias Ecto.Migration.Table
alias Ecto.Migration.Index
alias Ecto.Migration.Reference
+ alias Ecto.Migration.Constraint
@drops [:drop, :drop_if_exists]
@@ -608,6 +617,14 @@ if Code.ensure_loaded?(Postgrex.Connection) do
"ALTER TABLE #{quote_table(table.prefix, table.name)} RENAME #{quote_name(current_column)} TO #{quote_name(new_column)}"
end
+ def execute_ddl({:create, %Constraint{}=constraint}) do
+ "ALTER TABLE #{quote_table(constraint.table)} ADD #{new_constraint_expr(constraint)}"
+ end
+
+ def execute_ddl({:drop, %Constraint{}=constraint}) do
+ "ALTER TABLE #{quote_table(constraint.table)} DROP CONSTRAINT #{quote_name(constraint.name)}"
+ end
+
def execute_ddl(string) when is_binary(string), do: string
def execute_ddl(keyword) when is_list(keyword),
@@ -687,6 +704,13 @@ if Code.ensure_loaded?(Postgrex.Connection) do
defp null_expr(true), do: "NULL"
defp null_expr(_), do: []
+ defp new_constraint_expr(%Ecto.Migration.Constraint{check: check} = constraint) when is_binary(check) do
+ "CONSTRAINT #{quote_name(constraint.name)} CHECK (#{check})"
+ end
+ defp new_constraint_expr(%Ecto.Migration.Constraint{exclude: exclude} = constraint) when is_binary(exclude) do
+ "CONSTRAINT #{quote_name(constraint.name)} EXCLUDE USING #{exclude}"
+ end
+
defp default_expr({:ok, nil}, _type),
do: "DEFAULT NULL"
defp default_expr({:ok, []}, type),
View
@@ -1395,6 +1395,25 @@ defmodule Ecto.Changeset do
end
## Constraints
+ @doc """
+ Checks for a check constraint in the given field.
+
+ The check constraint works by relying on the database to check
+ if the check constraint has been violated or not and, if so,
+ Ecto converts it into a changeset error.
+
+ ## Options
+
+ * `:message` - the message in case the constraint check fails.
+ Defaults to something like "violates check 'products_price_check'"
+ * `:name` - the name of the constraint. Required.
+
+ """
+ def check_constraint(changeset, field, opts \\ []) do
+ constraint = opts[:name] || raise ArgumentError, "must supply the name of the constraint"
+ message = opts[:message] || "violates check '#{constraint}'"
+ add_constraint(changeset, :check, to_string(constraint), field, message)
+ end
@doc """
Checks for a unique constraint in the given field.
View
@@ -177,6 +177,23 @@ defmodule Ecto.Migration do
}
end
+ defmodule Constraint do
+ @moduledoc """
+ Defines a Constraint struct used in migrations.
+ """
+ defstruct name: nil,
+ table: nil,
+ check: nil,
+ exclude: nil
+
+ @type t :: %__MODULE__{
+ name: atom,
+ table: atom,
+ check: String.t | nil,
+ exclude: String.t | nil
+ }
+ end
+
alias Ecto.Migration.Runner
@doc false
@@ -263,16 +280,20 @@ defmodule Ecto.Migration do
end
@doc """
- Creates an index or a table with only `:id` field.
+ Creates one of the following:
+
+ * an index
+ * a table with only an `:id` field
+ * a constraint
When reversing (in `change` running backward) indexes are only dropped if they
exist and no errors are raised. To enforce dropping an index use `drop/1`.
## Examples
create index(:posts, [:name])
-
create table(:version)
+ create constraint(:products, "price_must_be_positive", check: "price > 0")
"""
def create(%Index{} = index) do
@@ -284,6 +305,10 @@ defmodule Ecto.Migration do
table
end
+ def create(%Constraint{} = constraint) do
+ Runner.execute {:create, constraint}
+ end
+
@doc """
Creates an index or a table with only `:id` field if one does not yet exist.
@@ -314,14 +339,23 @@ defmodule Ecto.Migration do
end
@doc """
- Drops a table or index.
+ Drops one of the following:
+
+ * an index
+ * a table
+ * a constraint
## Examples
drop index(:posts, [:name])
drop table(:posts)
+ drop constraint(:products, name: "price_must_be_positive")
"""
+ def drop(%Constraint{} = constraint) do
+ Runner.execute {:drop, constraint}
+ end
+
def drop(%{} = index_or_table) do
Runner.execute {:drop, __prefix__(index_or_table)}
index_or_table
@@ -656,6 +690,25 @@ defmodule Ecto.Migration do
reference
end
+ @doc ~S"""
+ Defines a constraint (either a check constraint or an exclude constraint) to be evaluated by the database when a row is inserted or updated.
+
+ ## Examples
+
+ create constraint(:users, :price_must_be_positive, check: "price > 0")
+ create constraint(:size_ranges, :no_overlap, exclude: ~s|gist (int4range("from", "to", '[]') WITH &&)|
+ drop constraint(:products, "price_must_be_positive")
+
+ ## Options
+
+ * `:check` - The expression to evaluate on a row. Required when creating.
+ * `:name` - The name of the constraint - required.
+
+ """
+ def constraint(table, name, opts \\ [] ) do
+ struct(%Constraint{table: table, name: name}, opts)
+ end
+
@doc """
Executes queue migration commands.
@@ -8,6 +8,7 @@ defmodule Ecto.Migration.Runner do
alias Ecto.Migration.Table
alias Ecto.Migration.Index
+ alias Ecto.Migration.Constraint
@opts [timeout: :infinity, log: false]
@@ -176,6 +177,8 @@ defmodule Ecto.Migration.Runner do
do: {:rename, table_new, table_current}
defp reverse({:rename, %Table{}=table, current_column, new_column}),
do: {:rename, table, new_column, current_column}
+ defp reverse({command, %Constraint{}=constraint}) when command in @creates,
+ do: {:drop, constraint}
defp reverse(_command), do: false
defp table_reverse([]), do: []
@@ -238,6 +241,18 @@ defmodule Ecto.Migration.Runner do
defp command({:rename, %Table{} = table, current_column, new_column}),
do: "rename column #{current_column} to #{new_column} on table #{quote_table(table.prefix, table.name)}"
+ defp command({:create, %Constraint{check: nil, exclude: nil}}),
+ do: raise ArgumentError, "a constraint must have either a check or exclude option"
+ defp command({:create, %Constraint{check: check, exclude: exclude}}) when is_binary(check) and is_binary(exclude),
+ do: raise ArgumentError, "a constraint must not have both check and exclude options"
+ defp command({:create, %Constraint{check: check} = constraint}) when is_binary(check),
+ do: "ALTER TABLE #{quote_table(constraint.table)} ADD CONSTRAINT #{constraint.name} CHECK (#{constraint.check})"
+ defp command({:create, %Constraint{exclude: exclude} = constraint}) when is_binary(exclude),
+ do: "ALTER TABLE #{quote_table(constraint.table)} ADD CONSTRAINT #{constraint.name} EXCLUDE USING #{constraint.exclude})"
+
+ defp command({:drop, %Constraint{} = constraint}),
+ do: "ALTER TABLE #{quote_table(constraint.table)} DROP CONSTRAINT #{constraint.name}"
+
defp quote_table(nil, name), do: quote_table(name)
defp quote_table(prefix, name), do: quote_table(prefix) <> "." <> quote_table(name)
defp quote_table(name) when is_atom(name),
Oops, something went wrong.

0 comments on commit 6d9de1f

Please sign in to comment.