Repository: elixir-ecto/ecto
Pull request #2333 (2017-12-06 19:25:10)

Update Ecto.Schema.custom type & Ecto.Changeset.constraint


Patch:
diff --git a/lib/ecto/changeset.ex b/lib/ecto/changeset.ex
index 5be099a8..1aaf65ac 100644
--- a/lib/ecto/changeset.ex
+++ b/lib/ecto/changeset.ex
@@ -249,8 +249,9 @@ defmodule Ecto.Changeset do
 
   @type error :: {String.t, Keyword.t}
   @type action :: nil | :insert | :update | :delete | :replace | :ignore
-  @type constraint :: %{type: :unique, constraint: String.t, match: :exact | :suffix,
-                        field: atom, message: error}
+  @type constraint :: %{type: :check | :exclude | :foreign_key | :unique,
+                        constraint: String.t, match: :exact | :suffix | :prefix,
+                        field: atom, error_message: String.t, error_type: atom}
   @type data :: map()
   @type types :: map()
 
@@ -1992,7 +1993,7 @@ defmodule Ecto.Changeset do
     constraint = opts[:name] || raise ArgumentError, "must supply the name of the constraint"
     message    = message(opts, "is invalid")
     match_type = Keyword.get(opts, :match, :exact)
-    add_constraint(changeset, :check, to_string(constraint), match_type, field, {message, [constraint: :check]})
+    add_constraint(changeset, :check, to_string(constraint), match_type, field, message)
   end
 
   @doc """
@@ -2094,7 +2095,7 @@ defmodule Ecto.Changeset do
     constraint = opts[:name] || "#{get_source(changeset)}_#{get_field_source(changeset, field)}_index"
     message    = message(opts, "has already been taken")
     match_type = Keyword.get(opts, :match, :exact)
-    add_constraint(changeset, :unique, to_string(constraint), match_type, field, {message, [constraint: :unique]})
+    add_constraint(changeset, :unique, to_string(constraint), match_type, field, message)
   end
 
   @doc """
@@ -2141,7 +2142,7 @@ defmodule Ecto.Changeset do
   def foreign_key_constraint(changeset, field, opts \\ []) do
     constraint = opts[:name] || "#{get_source(changeset)}_#{get_field_source(changeset, field)}_fkey"
     message    = message(opts, "does not exist")
-    add_constraint(changeset, :foreign_key, to_string(constraint), :exact, field, {message, [constraint: :foreign]})
+    add_constraint(changeset, :foreign_key, to_string(constraint), :exact, field, message, :foreign)
   end
 
   @doc """
@@ -2189,7 +2190,7 @@ defmodule Ecto.Changeset do
       end
 
     message = message(opts, "does not exist")
-    add_constraint(changeset, :foreign_key, to_string(constraint), :exact, assoc, {message, [constraint: :assoc]})
+    add_constraint(changeset, :foreign_key, to_string(constraint), :exact, assoc, message, :assoc)
   end
 
   @doc """
@@ -2225,6 +2226,7 @@ defmodule Ecto.Changeset do
     * `:name` - the constraint name. By default, the constraint
       name is inferred from the association table + association
       field. May be required explicitly for complex cases
+
   """
   @spec no_assoc_constraint(t, atom, Keyword.t) :: t | no_return
   def no_assoc_constraint(changeset, assoc, opts \\ []) do
@@ -2239,7 +2241,7 @@ defmodule Ecto.Changeset do
             "no_assoc_constraint can only be added to has one/many associations, got: #{inspect other}"
       end
 
-    add_constraint(changeset, :foreign_key, to_string(constraint), :exact, assoc, {message, [constraint: :no_assoc]})
+    add_constraint(changeset, :foreign_key, to_string(constraint), :exact, assoc, message, :no_assoc)
   end
 
   @doc """
@@ -2266,21 +2268,33 @@ defmodule Ecto.Changeset do
     constraint = opts[:name] || "#{get_source(changeset)}_#{get_field_source(changeset, field)}_exclusion"
     message    = message(opts, "violates an exclusion constraint")
     match_type = Keyword.get(opts, :match, :exact)
-    add_constraint(changeset, :exclude, to_string(constraint), match_type, field, {message, [constraint: :exclusion]})
+    add_constraint(changeset, :exclude, to_string(constraint), match_type, field, message, :exclusion)
   end
 
   defp no_assoc_message(:one), do: "is still associated with this entry"
   defp no_assoc_message(:many), do: "are still associated with this entry"
 
+  defp add_constraint(changeset, type, constraint, match, field, message) do
+    add_constraint(changeset, type, constraint, match, field, message, type)
+  end
+
   defp add_constraint(%Changeset{constraints: constraints} = changeset,
-                      type, constraint, match, field, error)
-       when is_binary(constraint) and is_atom(field) and is_tuple(error) and is_atom(match)  do
+                      type, constraint, match, field, error_message, error_type)
+       when is_binary(constraint) and is_atom(field) and is_binary(error_message) do
     unless match in @match_types do
       raise ArgumentError, "invalid match type: #{inspect match}. Allowed match types: #{inspect @match_types}"
     end
 
-    %{changeset | constraints: [%{type: type, constraint: constraint, match: match,
-                                  field: field, error: error} | constraints]}
+    constraint = %{
+      constraint: constraint,
+      error_message: error_message,
+      error_type: error_type,
+      field: field,
+      match: match,
+      type: type
+    }
+
+    %{changeset | constraints: [constraint | constraints]}
   end
 
   defp get_source(%{data: %{__meta__: %{source: {_prefix, source}}}}) when is_binary(source),
diff --git a/lib/ecto/repo/schema.ex b/lib/ecto/repo/schema.ex
index 22d17f5f..3e4fe1a3 100644
--- a/lib/ecto/repo/schema.ex
+++ b/lib/ecto/repo/schema.ex
@@ -566,8 +566,8 @@ defmodule Ecto.Repo.Schema do
           end)
 
         case user_constraint do
-          %{field: field, error: error} ->
-            {field, error}
+          %{field: field, error_message: error_message, error_type: error_type} ->
+            {field, {error_message, [constraint: error_type, constraint_name: constraint]}}
           nil ->
             raise Ecto.ConstraintError, action: action, type: type,
                                         constraint: constraint, changeset: changeset
diff --git a/lib/ecto/type.ex b/lib/ecto/type.ex
index 76524be8..5117ca39 100644
--- a/lib/ecto/type.ex
+++ b/lib/ecto/type.ex
@@ -83,7 +83,7 @@ defmodule Ecto.Type do
   @type primitive :: base | composite
 
   @typedoc "Custom types are represented by user-defined modules."
-  @type custom    :: atom
+  @type custom    :: module
 
   @typep base      :: :integer | :float | :boolean | :string | :map |
                       :binary | :decimal | :id | :binary_id |


Test patch:
diff --git a/integration_test/cases/repo.exs b/integration_test/cases/repo.exs
index c791607e..b55b76b2 100644
--- a/integration_test/cases/repo.exs
+++ b/integration_test/cases/repo.exs
@@ -281,7 +281,7 @@ defmodule Ecto.Integration.RepoTest do
       changeset
       |> Ecto.Changeset.unique_constraint(:uuid)
       |> TestRepo.insert()
-    assert changeset.errors == [uuid: {"has already been taken", [constraint: :unique]}]
+    assert changeset.errors == [uuid: {"has already been taken", [constraint: :unique, constraint_name: "posts_uuid_index"]}]
     assert changeset.data.__meta__.state == :built
   end
 
@@ -298,7 +298,7 @@ defmodule Ecto.Integration.RepoTest do
       }
 
     [_, p2, _] = changeset.changes.posts
-    assert p2.errors == [uuid: {"has already been taken", [constraint: :unique]}]
+    assert p2.errors == [uuid: {"has already been taken", [constraint: :unique, constraint_name: "posts_uuid_index"]}]
   end
 
   @tag :id_type
@@ -311,7 +311,7 @@ defmodule Ecto.Integration.RepoTest do
       changeset
       |> Ecto.Changeset.unique_constraint(:uuid)
       |> TestRepo.insert()
-    assert changeset.errors == [uuid: {"has already been taken", [constraint: :unique]}]
+    assert changeset.errors == [uuid: {"has already been taken", [constraint: :unique, constraint_name: "customs_uuid_index"]}]
     assert changeset.data.__meta__.state == :built
   end
 
@@ -418,7 +418,7 @@ defmodule Ecto.Integration.RepoTest do
       changeset
       |> Ecto.Changeset.foreign_key_constraint(:post_id)
       |> TestRepo.insert()
-    assert changeset.errors == [post_id: {"does not exist", [constraint: :foreign]}]
+    assert changeset.errors == [post_id: {"does not exist", [constraint: :foreign, constraint_name: "comments_post_id_fkey"]}]
   end
 
   @tag :foreign_key_constraint
@@ -448,7 +448,7 @@ defmodule Ecto.Integration.RepoTest do
       changeset
       |> Ecto.Changeset.assoc_constraint(:post)
       |> TestRepo.insert()
-    assert changeset.errors == [post: {"does not exist", [constraint: :assoc]}]
+    assert changeset.errors == [post: {"does not exist", [constraint: :assoc, constraint_name: "comments_post_id_fkey"]}]
   end
 
   @tag :foreign_key_constraint
@@ -492,7 +492,7 @@ defmodule Ecto.Integration.RepoTest do
       |> Ecto.Changeset.change
       |> Ecto.Changeset.no_assoc_constraint(:permalink)
       |> TestRepo.delete()
-    assert changeset.errors == [permalink: {"is still associated with this entry", [constraint: :no_assoc]}]
+    assert changeset.errors == [permalink: {"is still associated with this entry", [constraint: :no_assoc, constraint_name: "permalinks_user_id_fkey"]}]
   end
 
   @tag :foreign_key_constraint
diff --git a/integration_test/pg/constraints_test.exs b/integration_test/pg/constraints_test.exs
index 33f4d7a0..206e2dec 100644
--- a/integration_test/pg/constraints_test.exs
+++ b/integration_test/pg/constraints_test.exs
@@ -65,7 +65,7 @@ defmodule Ecto.Integration.ConstraintsTest do
       overlapping_changeset
       |> Ecto.Changeset.exclusion_constraint(:from, name: :cannot_overlap)
       |> PoolRepo.insert()
-    assert changeset.errors == [from: {"violates an exclusion constraint", [constraint: :exclusion]}]
+    assert changeset.errors == [from: {"violates an exclusion constraint", [constraint: :exclusion, constraint_name: "cannot_overlap"]}]
     assert changeset.data.__meta__.state == :built
   end
 
@@ -85,7 +85,7 @@ defmodule Ecto.Integration.ConstraintsTest do
       changeset
       |> Ecto.Changeset.check_constraint(:price, name: :positive_price)
       |> PoolRepo.insert()
-    assert changeset.errors == [price: {"is invalid", [constraint: :check]}]
+    assert changeset.errors == [price: {"is invalid", [constraint: :check, constraint_name: "positive_price"]}]
     assert changeset.data.__meta__.state == :built
 
     # When the changeset does expect the db error and gives a custom message
@@ -94,7 +94,7 @@ defmodule Ecto.Integration.ConstraintsTest do
       changeset
       |> Ecto.Changeset.check_constraint(:price, name: :positive_price, message: "price must be greater than 0")
       |> PoolRepo.insert()
-    assert changeset.errors == [price: {"price must be greater than 0", [constraint: :check]}]
+    assert changeset.errors == [price: {"price must be greater than 0", [constraint: :check, constraint_name: "positive_price"]}]
     assert changeset.data.__meta__.state == :built
 
     # When the change does not violate the check constraint
diff --git a/test/ecto/changeset_test.exs b/test/ecto/changeset_test.exs
index 5690eff4..a340663f 100644
--- a/test/ecto/changeset_test.exs
+++ b/test/ecto/changeset_test.exs
@@ -1148,12 +1148,12 @@ defmodule Ecto.ChangesetTest do
     changeset = change(%Post{}) |> check_constraint(:title, name: :title_must_be_short)
     assert changeset.constraints ==
            [%{type: :check, field: :title, constraint: "title_must_be_short", match: :exact,
-              error: {"is invalid", [constraint: :check]}}]
+              error_message: "is invalid", error_type: :check}]
 
     changeset = change(%Post{}) |> check_constraint(:title, name: :title_must_be_short, message: "cannot be more than 15 characters")
     assert changeset.constraints ==
            [%{type: :check, field: :title, constraint: "title_must_be_short", match: :exact,
-              error: {"cannot be more than 15 characters", [constraint: :check]}}]
+              error_message: "cannot be more than 15 characters", error_type: :check}]
 
     assert_raise ArgumentError, ~r/invalid match type: :invalid/, fn ->
       change(%Post{}) |> check_constraint(:title, name: :whatever, match: :invalid, message: "match is invalid")
@@ -1169,19 +1169,19 @@ defmodule Ecto.ChangesetTest do
 
     assert changeset.constraints ==
            [%{type: :unique, field: :title, constraint: "posts_title_index", match: :exact,
-              error: {"has already been taken", [constraint: :unique]}}]
+              error_message: "has already been taken", error_type: :unique}]
 
     changeset = change(%Post{}) |> unique_constraint(:title, name: :whatever, message: "is taken")
     assert changeset.constraints ==
-           [%{type: :unique, field: :title, constraint: "whatever", match: :exact, error: {"is taken", [constraint: :unique]}}]
+           [%{type: :unique, field: :title, constraint: "whatever", match: :exact, error_message: "is taken", error_type: :unique}]
 
     changeset = change(%Post{}) |> unique_constraint(:title, name: :whatever, match: :suffix, message: "is taken")
     assert changeset.constraints ==
-           [%{type: :unique, field: :title, constraint: "whatever", match: :suffix, error: {"is taken", [constraint: :unique]}}]
+           [%{type: :unique, field: :title, constraint: "whatever", match: :suffix, error_message: "is taken", error_type: :unique}]
 
     changeset = change(%Post{}) |> unique_constraint(:title, name: :whatever, match: :prefix, message: "is taken")
     assert changeset.constraints ==
-           [%{type: :unique, field: :title, constraint: "whatever", match: :prefix, error: {"is taken", [constraint: :unique]}}]
+           [%{type: :unique, field: :title, constraint: "whatever", match: :prefix, error_message: "is taken", error_type: :unique}]
 
     assert_raise ArgumentError, ~r/invalid match type: :invalid/, fn ->
       change(%Post{}) |> unique_constraint(:title, name: :whatever, match: :invalid, message: "is taken")
@@ -1193,15 +1193,15 @@ defmodule Ecto.ChangesetTest do
 
     assert changeset.constraints ==
            [%{type: :unique, field: :permalink, constraint: "posts_url_index", match: :exact,
-              error: {"has already been taken", [constraint: :unique]}}]
+              error_message: "has already been taken", error_type: :unique}]
 
     changeset = change(%Post{}) |> unique_constraint(:permalink, name: :whatever, message: "is taken")
     assert changeset.constraints ==
-           [%{type: :unique, field: :permalink, constraint: "whatever", match: :exact, error: {"is taken", [constraint: :unique]}}]
+           [%{type: :unique, field: :permalink, constraint: "whatever", match: :exact, error_message: "is taken", error_type: :unique}]
 
     changeset = change(%Post{}) |> unique_constraint(:permalink, name: :whatever, match: :suffix, message: "is taken")
     assert changeset.constraints ==
-           [%{type: :unique, field: :permalink, constraint: "whatever", match: :suffix, error: {"is taken", [constraint: :unique]}}]
+           [%{type: :unique, field: :permalink, constraint: "whatever", match: :suffix, error_message: "is taken", error_type: :unique}]
 
     assert_raise ArgumentError, ~r/invalid match type: :invalid/, fn ->
       change(%Post{}) |> unique_constraint(:permalink, name: :whatever, match: :invalid, message: "is taken")
@@ -1212,44 +1212,44 @@ defmodule Ecto.ChangesetTest do
     changeset = change(%Comment{}) |> foreign_key_constraint(:post_id)
     assert changeset.constraints ==
            [%{type: :foreign_key, field: :post_id, constraint: "comments_post_id_fkey", match: :exact,
-              error: {"does not exist", [constraint: :foreign]}}]
+              error_message: "does not exist", error_type: :foreign}]
 
     changeset = change(%Comment{}) |> foreign_key_constraint(:post_id, name: :whatever, message: "is not available")
     assert changeset.constraints ==
-           [%{type: :foreign_key, field: :post_id, constraint: "whatever", match: :exact, error: {"is not available", [constraint: :foreign]}}]
+           [%{type: :foreign_key, field: :post_id, constraint: "whatever", match: :exact, error_message: "is not available", error_type: :foreign}]
   end
 
   test "foreign_key_constraint/3 on field with :source" do
     changeset = change(%Post{}) |> foreign_key_constraint(:permalink)
     assert changeset.constraints ==
            [%{type: :foreign_key, field: :permalink, constraint: "posts_url_fkey", match: :exact,
-              error: {"does not exist", [constraint: :foreign]}}]
+              error_message: "does not exist", error_type: :foreign}]
 
     changeset = change(%Post{}) |> foreign_key_constraint(:permalink, name: :whatever, message: "is not available")
     assert changeset.constraints ==
-           [%{type: :foreign_key, field: :permalink, constraint: "whatever", match: :exact, error: {"is not available", [constraint: :foreign]}}]
+           [%{type: :foreign_key, field: :permalink, constraint: "whatever", match: :exact, error_message: "is not available", error_type: :foreign}]
   end
 
   test "assoc_constraint/3" do
     changeset = change(%Comment{}) |> assoc_constraint(:post)
     assert changeset.constraints ==
            [%{type: :foreign_key, field: :post, constraint: "comments_post_id_fkey", match: :exact,
-              error: {"does not exist", [constraint: :assoc]}}]
+              error_message: "does not exist", error_type: :assoc}]
 
     changeset = change(%Comment{}) |> assoc_constraint(:post, name: :whatever, message: "is not available")
     assert changeset.constraints ==
-           [%{type: :foreign_key, field: :post, constraint: "whatever", match: :exact, error: {"is not available", [constraint: :assoc]}}]
+           [%{type: :foreign_key, field: :post, constraint: "whatever", match: :exact, error_message: "is not available", error_type: :assoc}]
   end
 
   test "assoc_constraint/3 on field with :source" do
     changeset = change(%Post{}) |> assoc_constraint(:category)
     assert changeset.constraints ==
            [%{type: :foreign_key, field: :category, constraint: "posts_category_id_fkey", match: :exact,
-              error: {"does not exist", [constraint: :assoc]}}]
+              error_message: "does not exist", error_type: :assoc}]
 
     changeset = change(%Post{}) |> assoc_constraint(:category, name: :whatever, message: "is not available")
     assert changeset.constraints ==
-           [%{type: :foreign_key, field: :category, constraint: "whatever", match: :exact, error: {"is not available", [constraint: :assoc]}}]
+           [%{type: :foreign_key, field: :category, constraint: "whatever", match: :exact, error_message: "is not available", error_type: :assoc}]
   end
 
   test "assoc_constraint/3 with errors" do
@@ -1268,24 +1268,24 @@ defmodule Ecto.ChangesetTest do
     changeset = change(%Post{}) |> no_assoc_constraint(:comments)
     assert changeset.constraints ==
            [%{type: :foreign_key, field: :comments, constraint: "comments_post_id_fkey", match: :exact,
-              error: {"are still associated with this entry", [constraint: :no_assoc]}}]
+              error_message: "are still associated with this entry", error_type: :no_assoc}]
 
     changeset = change(%Post{}) |> no_assoc_constraint(:comments, name: :whatever, message: "exists")
     assert changeset.constraints ==
            [%{type: :foreign_key, field: :comments, constraint: "whatever", match: :exact,
-              error: {"exists", [constraint: :no_assoc]}}]
+              error_message: "exists", error_type: :no_assoc}]
   end
 
   test "no_assoc_constraint/3 with has_one" do
     changeset = change(%Post{}) |> no_assoc_constraint(:comment)
     assert changeset.constraints ==
            [%{type: :foreign_key, field: :comment, constraint: "comments_post_id_fkey", match: :exact,
-              error: {"is still associated with this entry", [constraint: :no_assoc]}}]
+              error_message: "is still associated with this entry", error_type: :no_assoc}]
 
     changeset = change(%Post{}) |> no_assoc_constraint(:comment, name: :whatever, message: "exists")
     assert changeset.constraints ==
            [%{type: :foreign_key, field: :comment, constraint: "whatever", match: :exact,
-              error: {"exists", [constraint: :no_assoc]}}]
+              error_message: "exists", error_type: :no_assoc}]
   end
 
   test "no_assoc_constraint/3 with errors" do
@@ -1304,12 +1304,12 @@ defmodule Ecto.ChangesetTest do
     changeset = change(%Post{}) |> exclusion_constraint(:title)
     assert changeset.constraints ==
            [%{type: :exclude, field: :title, constraint: "posts_title_exclusion", match: :exact,
-              error: {"violates an exclusion constraint", [constraint: :exclusion]}}]
+              error_message: "violates an exclusion constraint", error_type: :exclusion}]
 
     changeset = change(%Post{}) |> exclusion_constraint(:title, name: :whatever, message: "is invalid")
     assert changeset.constraints ==
            [%{type: :exclude, field: :title, constraint: "whatever", match: :exact,
-              error: {"is invalid", [constraint: :exclusion]}}]
+              error_message: "is invalid", error_type: :exclusion}]
 
     assert_raise ArgumentError, ~r/invalid match type: :invalid/, fn ->
       change(%Post{}) |> exclusion_constraint(:title, name: :whatever, match: :invalid, message: "match is invalid")
diff --git a/test/ecto/multi_test.exs b/test/ecto/multi_test.exs
index b443236d..68c51783 100644
--- a/test/ecto/multi_test.exs
+++ b/test/ecto/multi_test.exs
@@ -405,7 +405,7 @@ defmodule Ecto.MultiTest do
       assert {:messages, [{:insert, {nil, "comments"}}]} == Process.info(self(), :messages)
       assert %Comment{} = changes.insert
       assert "ok" == changes.run
-      assert error.errors == [x: {"has already been taken", [constraint: :unique]}]
+      assert error.errors == [x: {"has already been taken", [constraint: :unique, constraint_name: "comments_x_index"]}]
       refute Map.has_key?(changes, :update)
     end