Skip to content

Commit

Permalink
Better support for check and exclusion constraints
Browse files Browse the repository at this point in the history
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
nathanl committed Jan 8, 2016
1 parent dc0ab7d commit 6d9de1f
Show file tree
Hide file tree
Showing 8 changed files with 235 additions and 25 deletions.
69 changes: 69 additions & 0 deletions integration_test/pg/check_constraint_test.exs
@@ -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
Expand All @@ -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

Expand Down
24 changes: 24 additions & 0 deletions lib/ecto/adapters/postgres/connection.ex
Expand Up @@ -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
Expand All @@ -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: []
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
19 changes: 19 additions & 0 deletions lib/ecto/changeset.ex
Expand Up @@ -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.
Expand Down
59 changes: 56 additions & 3 deletions lib/ecto/migration.ex
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions lib/ecto/migration/runner.ex
Expand Up @@ -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]

Expand Down Expand Up @@ -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: []
Expand Down Expand Up @@ -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),
Expand Down

0 comments on commit 6d9de1f

Please sign in to comment.