Repository: elixir-ecto/ecto
Pull request #2180 (2017-08-23 05:40:07)

[WIP] Refactoring validations
Related to https://github.com/elixir-ecto/ecto/issues/2179

ebert[bot]: Hello, @yordis! This is your first Pull Request that will be reviewed by [Ebert](https://ebertapp.io/), an automatic Code Review service. It will leave comments on this diff with potential issues and style violations found in the code as you push new commits. You can also see all the issues found on this Pull Request on its [review page](https://ebertapp.io/github/elixir-ecto/ecto/pulls/2180). Please check our [documentation](https://docs.ebertapp.io) for more information. 

Patch:
diff --git a/README.md b/README.md
index bf128a72..0055ab18 100644
--- a/README.md
+++ b/README.md
@@ -105,9 +105,9 @@ We are currently looking for contributions to add support for other SQL database
 
 | Branch | Support                  |
 | ------ | ------------------------ |
-| v2.2   | In development (master)  |
-| v2.1   | Bug fixes                |
-| v2.0   | Security patches only    |
+| v2.2   | Bug fixes                |
+| v2.1   | Security patches only    |
+| v2.0   | Unsupported from 08/2017 |
 | v1.1   | Security patches only    |
 | v1.0   | Unsupported from 05/2017 |
 
diff --git a/lib/ecto/adapters/mysql/connection.ex b/lib/ecto/adapters/mysql/connection.ex
index 710d74a1..19077f5d 100644
--- a/lib/ecto/adapters/mysql/connection.ex
+++ b/lib/ecto/adapters/mysql/connection.ex
@@ -142,9 +142,9 @@ if Code.ensure_loaded?(Mariaex) do
       quoted = quote_name(field)
       [" ON DUPLICATE KEY UPDATE ", quoted, " = " | quoted]
     end
-    defp on_conflict({:replace_all, _, []}, header) do
+    defp on_conflict({fields, _, []}, _header) when is_list(fields) do
       [" ON DUPLICATE KEY UPDATE " |
-       intersperse_map(header, ?,, fn field ->
+       intersperse_map(fields, ?,, fn field ->
          quoted = quote_name(field)
          [quoted, " = VALUES(", quoted, ?)]
        end)]
diff --git a/lib/ecto/adapters/postgres/connection.ex b/lib/ecto/adapters/postgres/connection.ex
index 7db48ebe..a292d049 100644
--- a/lib/ecto/adapters/postgres/connection.ex
+++ b/lib/ecto/adapters/postgres/connection.ex
@@ -180,8 +180,8 @@ if Code.ensure_loaded?(Postgrex) do
       do: []
     defp on_conflict({:nothing, _, targets}, _header),
       do: [" ON CONFLICT ", conflict_target(targets) | "DO NOTHING"]
-    defp on_conflict({:replace_all, _, targets}, header),
-      do: [" ON CONFLICT ", conflict_target(targets), "DO " | replace_all(header)]
+    defp on_conflict({fields, _, targets}, _header) when is_list(fields),
+      do: [" ON CONFLICT ", conflict_target(targets), "DO " | replace(fields)]
     defp on_conflict({query, _, targets}, _header),
       do: [" ON CONFLICT ", conflict_target(targets), "DO " | update_all(query, "UPDATE SET ")]
 
@@ -192,9 +192,9 @@ if Code.ensure_loaded?(Postgrex) do
     defp conflict_target(targets),
       do: [?(, intersperse_map(targets, ?,, &quote_name/1), ?), ?\s]
 
-    defp replace_all(header) do
+    defp replace(fields) do
       ["UPDATE SET " |
-       intersperse_map(header, ?,, fn field ->
+       intersperse_map(fields, ?,, fn field ->
          quoted = quote_name(field)
          [quoted, " = ", "EXCLUDED." | quoted]
        end)]
diff --git a/lib/ecto/changeset.ex b/lib/ecto/changeset.ex
index bdef4d62..20584c3c 100644
--- a/lib/ecto/changeset.ex
+++ b/lib/ecto/changeset.ex
@@ -482,7 +482,7 @@ defmodule Ecto.Changeset do
   defp type!(types, key) do
     case types do
       %{^key => {tag, _}} when tag in @relations ->
-        raise "casting #{tag}s with cast/4 is not supported, use cast_#{tag}/3 instead"
+        raise "casting #{tag}s with cast/4 for #{inspect key} field is not supported, use cast_#{tag}/3 instead"
       %{^key => type} ->
         type
       _ ->
@@ -717,7 +717,7 @@ defmodule Ecto.Changeset do
               missing_relation(%{changeset | changes: Map.put(changes, key, change),
                                  valid?: changeset.valid? and relation_valid?}, key, current, required?, relation, opts)
             :error ->
-              %{changeset | errors: [{key, {message(opts, :invalid_message, "is invalid"), [type: expected_relation_type(relation)]}} | changeset.errors], valid?: false}
+              %{changeset | errors: [{key, {message(opts, :invalid_message, "is invalid"), [validation: type, type: expected_relation_type(relation)]}} | changeset.errors], valid?: false}
             _ -> # ignore or ok with change == original
               missing_relation(changeset, key, current, required?, relation, opts)
           end
@@ -1629,21 +1629,21 @@ defmodule Ecto.Changeset do
 
   defp wrong_length(_type, value, value, _opts), do: nil
   defp wrong_length(:string, _length, value, opts), do:
-    {message(opts, "should be %{count} character(s)"), count: value, validation: :length, is: value}
+    {message(opts, "should be %{count} character(s)"), count: value, validation: :length, kind: :is}
   defp wrong_length(:list, _length, value, opts), do:
-    {message(opts, "should have %{count} item(s)"), count: value, validation: :length, is: value}
+    {message(opts, "should have %{count} item(s)"), count: value, validation: :length, kind: :is}
 
   defp too_short(_type, length, value, _opts) when length >= value, do: nil
   defp too_short(:string, _length, value, opts), do:
-    {message(opts, "should be at least %{count} character(s)"), count: value, validation: :length, min: value}
+    {message(opts, "should be at least %{count} character(s)"), count: value, validation: :length, kind: :min}
   defp too_short(:list, _length, value, opts), do:
-    {message(opts, "should have at least %{count} item(s)"), count: value, validation: :length, min: value}
+    {message(opts, "should have at least %{count} item(s)"), count: value, validation: :length, kind: :min}
 
   defp too_long(_type, length, value, _opts) when length <= value, do: nil
   defp too_long(:string, _length, value, opts), do:
-    {message(opts, "should be at most %{count} character(s)"), count: value, validation: :length, max: value}
+    {message(opts, "should be at most %{count} character(s)"), count: value, validation: :length, kind: :max}
   defp too_long(:list, _length, value, opts), do:
-    {message(opts, "should have at most %{count} item(s)"), count: value, validation: :length, max: value}
+    {message(opts, "should have at most %{count} item(s)"), count: value, validation: :length, kind: :max}
 
   @doc """
   Validates the properties of a number.
@@ -1690,14 +1690,14 @@ defmodule Ecto.Changeset do
     result = Decimal.cmp(value, Decimal.new(target_value))
     case decimal_compare(result, spec_key) do
       true  -> nil
-      false -> [{field, {message, validation: :number, number: target_value}}]
+      false -> [{field, {message, validation: :number, kind: spec_key, number: target_value}}]
     end
   end
 
-  defp validate_number(field, value, message, _spec_key, spec_function, target_value) do
+  defp validate_number(field, value, message, spec_key, spec_function, target_value) do
     case apply(spec_function, [value, target_value]) do
       true  -> nil
-      false -> [{field, {message, validation: :number, number: target_value}}]
+      false -> [{field, {message, validation: :number, kind: spec_key, number: target_value}}]
     end
   end
 
@@ -2054,7 +2054,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, []})
+    add_constraint(changeset, :unique, to_string(constraint), match_type, field, {message, [constraint: :unique]})
   end
 
   @doc """
@@ -2101,7 +2101,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, []})
+    add_constraint(changeset, :foreign_key, to_string(constraint), :exact, field, {message, [constraint: :foreign]})
   end
 
   @doc """
@@ -2149,7 +2149,7 @@ defmodule Ecto.Changeset do
       end
 
     message = message(opts, "does not exist")
-    add_constraint(changeset, :foreign_key, to_string(constraint), :exact, assoc, {message, []})
+    add_constraint(changeset, :foreign_key, to_string(constraint), :exact, assoc, {message, [constraint: :assoc]})
   end
 
   @doc """
@@ -2199,7 +2199,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, []})
+    add_constraint(changeset, :foreign_key, to_string(constraint), :exact, assoc, {message, [constraint: :no_assoc]})
   end
 
   @doc """
diff --git a/lib/ecto/changeset/relation.ex b/lib/ecto/changeset/relation.ex
index 1f1e320b..902a2cfe 100644
--- a/lib/ecto/changeset/relation.ex
+++ b/lib/ecto/changeset/relation.ex
@@ -29,7 +29,12 @@ defmodule Ecto.Changeset.Relation do
   """
   def empty?(%{cardinality: _}, %NotLoaded{}), do: true
   def empty?(%{cardinality: :many}, []), do: true
-  def empty?(%{cardinality: :many}, changes), do: Enum.all?(changes, & &1.action in [:replace, :delete])
+  def empty?(%{cardinality: :many}, changes) do
+    Enum.all?(changes, fn
+      %Changeset{action: action} when action in [:replace, :delete] -> true
+      _ -> false
+    end)
+  end
   def empty?(%{cardinality: :one}, nil), do: true
   def empty?(%{}, _), do: false
 
diff --git a/lib/ecto/date_time.ex b/lib/ecto/date_time.ex
index 929fc0e0..22f6efa9 100644
--- a/lib/ecto/date_time.ex
+++ b/lib/ecto/date_time.ex
@@ -86,6 +86,15 @@ defmodule Ecto.DateTime.Utils do
 end
 
 defmodule Ecto.Date do
+  @moduledoc """
+  A deprecated Ecto type for dates.
+
+  This type is deprecated in favour of the `:date` type.
+  """
+
+  @behaviour Ecto.Type
+  defstruct [:year, :month, :day]
+
   import Ecto.DateTime.Utils
 
   @doc """
@@ -96,13 +105,6 @@ defmodule Ecto.Date do
   """
   defdelegate compare(t1, t2), to: Ecto.DateTime.Utils
 
-  @moduledoc """
-  An Ecto type for dates.
-  """
-
-  @behaviour Ecto.Type
-  defstruct [:year, :month, :day]
-
   @doc """
   The Ecto primitive type.
   """
@@ -223,6 +225,15 @@ defmodule Ecto.Date do
 end
 
 defmodule Ecto.Time do
+  @moduledoc """
+  A deprecated Ecto type for time.
+
+  This type is deprecated in favour of the `:time` type.
+  """
+
+  @behaviour Ecto.Type
+  defstruct [:hour, :min, :sec, usec: 0]
+
   import Ecto.DateTime.Utils
 
   @doc """
@@ -233,13 +244,6 @@ defmodule Ecto.Time do
   """
   defdelegate compare(t1, t2), to: Ecto.DateTime.Utils
 
-  @moduledoc """
-  An Ecto type for time.
-  """
-
-  @behaviour Ecto.Type
-  defstruct [:hour, :min, :sec, usec: 0]
-
   @doc """
   The Ecto primitive type.
   """
@@ -384,6 +388,15 @@ defmodule Ecto.Time do
 end
 
 defmodule Ecto.DateTime do
+  @moduledoc """
+  A deprecated Ecto type that includes a date and a time.
+
+  This type is deprecated in favour of the `:naive_datetime` type.
+  """
+
+  @behaviour Ecto.Type
+  defstruct [:year, :month, :day, :hour, :min, :sec, usec: 0]
+
   import Ecto.DateTime.Utils
 
   @unix_epoch :calendar.datetime_to_gregorian_seconds {{1970, 1, 1}, {0, 0, 0}}
@@ -396,13 +409,6 @@ defmodule Ecto.DateTime do
   """
   defdelegate compare(t1, t2), to: Ecto.DateTime.Utils
 
-  @moduledoc """
-  An Ecto type that includes a date and a time.
-  """
-
-  @behaviour Ecto.Type
-  defstruct [:year, :month, :day, :hour, :min, :sec, usec: 0]
-
   @doc """
   The Ecto primitive type.
   """
diff --git a/lib/ecto/repo.ex b/lib/ecto/repo.ex
index 838098d3..9ac393f3 100644
--- a/lib/ecto/repo.ex
+++ b/lib/ecto/repo.ex
@@ -544,8 +544,9 @@ defmodule Ecto.Repo do
     * `:prefix` - The prefix to run the query on (such as the schema path
       in Postgres or the database in MySQL).
     * `:on_conflict` - It may be one of `:raise` (the default), `:nothing`,
-      `:replace_all`, a keyword list of update instructions or an `Ecto.Query`
-      query for updates. See the "Upserts" section for more information.
+      `:replace_all`, `:replace_all_except_primary_key`, a keyword list of
+      update instructions or an `Ecto.Query` query for updates. See the
+      "Upserts" section for more information.
     * `:conflict_target` - Which columns to verify for conflicts. If
       none is specified, the conflict target is left up to the database
       and is usually made of primary keys and/or unique/exclusion constraints.
@@ -570,6 +571,8 @@ defmodule Ecto.Repo do
     * `:replace_all` - replace all values on the existing row with the values
       in the excluded row (the corresponding record given in the function
       parameters)
+    * `:replace_all_except_primary_key` - same as above except primary keys are
+      not replaced. This option requires a schema
     * a keyword list of update instructions - such as the one given to
       `c:update_all/3`, for example: `[set: [title: "new title"]]`
     * an `Ecto.Query` that will act as an `UPDATE` statement, such as the
@@ -701,8 +704,9 @@ defmodule Ecto.Repo do
       in Postgres or the database in MySQL). This overrides the prefix set
       in the struct.
     * `:on_conflict` - It may be one of `:raise` (the default), `:nothing`,
-      `:replace_all`, a keyword list of update instructions or an `Ecto.Query`
-      query for updates. See the "Upserts" section for more information.
+      `:replace_all`, `:replace_all_except_primary_key`, a keyword list of
+      update instructions or an `Ecto.Query` query for updates. See the
+      "Upserts" section for more information.
     * `:conflict_target` - Which columns to verify for conflicts. If
       none is specified, the conflict target is left up to the database
       and is usually made of primary keys and/or unique/exclusion constraints.
@@ -729,7 +733,9 @@ defmodule Ecto.Repo do
     * `:raise` - raises if there is a conflicting primary key or unique index
     * `:nothing` - ignores the error in case of conflicts
     * `:replace_all` - replace all values on the existing row with the values
-      in the excluded row (the record given in the function parameters)
+      in the excluded row except (the record given in the function parameters)
+    * `:replace_all_except_primary_key` - same as above except primary keys are
+      not replaced
     * a keyword list of update instructions - such as the one given to
       `c:update_all/3`, for example: `[set: [title: "new title"]]`
     * an `Ecto.Query` that will act as an `UPDATE` statement, such as the
@@ -781,7 +787,7 @@ defmodule Ecto.Repo do
   database, you have three options:
 
     * Use `on_conflict: :replace_all`, although that will replace all
-      fields in the database with current ones:
+      fields in the database with current ones (except for primary keys):
 
           MyRepo.insert(%Post{title: "this is unique"},
                         on_conflict: :replace_all, conflict_target: :title)
diff --git a/lib/ecto/repo/schema.ex b/lib/ecto/repo/schema.ex
index e615470d..ab3a69c4 100644
--- a/lib/ecto/repo/schema.ex
+++ b/lib/ecto/repo/schema.ex
@@ -46,10 +46,13 @@ defmodule Ecto.Repo.Schema do
     {on_conflict, opts} = Keyword.pop(opts, :on_conflict, :raise)
     {conflict_target, opts} = Keyword.pop(opts, :conflict_target, [])
     conflict_target = conflict_target(conflict_target, dumper)
-    on_conflict = on_conflict(on_conflict, conflict_target, metadata, counter, adapter)
 
+    keys = Map.keys(header)
+    on_conflict =
+      on_conflict(on_conflict, conflict_target, metadata, keys, counter, adapter)
     {count, rows} =
-      adapter.insert_all(repo, metadata, Map.keys(header), rows, on_conflict, return_sources, opts)
+      adapter.insert_all(repo, metadata, keys, rows, on_conflict, return_sources, opts)
+
     {count, postprocess(rows, return_fields_or_types, adapter, schema, metadata)}
   end
 
@@ -68,18 +71,14 @@ defmodule Ecto.Repo.Schema do
   end
 
   defp extract_header_and_fields(rows, schema, dumper, autogen_id, adapter) do
-    header = init_header(autogen_id)
     mapper = init_mapper(schema, dumper, adapter)
 
-    Enum.map_reduce(rows, header, fn fields, header ->
+    Enum.map_reduce(rows, %{}, fn fields, header ->
       {fields, header} = Enum.map_reduce(fields, header, mapper)
-      {autogenerate_id(autogen_id, fields, adapter), header}
+      autogenerate_id(autogen_id, fields, header, adapter)
     end)
   end
 
-  defp init_header(nil), do: %{}
-  defp init_header({_, source, _}), do: %{source => true}
-
   defp init_mapper(nil, _dumper, _adapter) do
     fn {field, value}, acc ->
       case Ecto.DataType.dump(value) do
@@ -104,16 +103,16 @@ defmodule Ecto.Repo.Schema do
     end
   end
 
-  defp autogenerate_id(nil, fields, _adapter), do: fields
-  defp autogenerate_id({key, source, type}, fields, adapter) do
+  defp autogenerate_id(nil, fields, header, _adapter), do: {fields, header}
+  defp autogenerate_id({key, source, type}, fields, header, adapter) do
     case :lists.keyfind(key, 1, fields) do
       {^key, _} ->
-        fields
+        {fields, header}
       false ->
         if value = adapter.autogenerate(type) do
-          [{source, value} | fields]
+          {[{source, value} | fields], Map.put(header, source, true)}
         else
-          fields
+          {fields, header}
         end
     end
   end
@@ -203,9 +202,11 @@ defmodule Ecto.Repo.Schema do
 
         {changes, extra, return_types, return_sources} =
           autogenerate_id(autogen_id, changeset.changes, return_types, return_sources, adapter)
-        {changes, autogen} = dump_changes!(:insert, Map.take(changes, fields), schema, extra, dumper, adapter)
-        on_conflict = on_conflict(on_conflict, conflict_target, metadata,
-                                  fn -> length(changes) end, adapter)
+        {changes, autogen} =
+          dump_changes!(:insert, Map.take(changes, fields), schema, extra, dumper, adapter)
+        on_conflict =
+          on_conflict(on_conflict, conflict_target, metadata, Keyword.keys(changes),
+                      fn -> length(changes) end, adapter)
 
         args = [repo, metadata, changes, on_conflict, return_sources, opts]
         case apply(changeset, adapter, :insert, args) do
@@ -492,7 +493,7 @@ defmodule Ecto.Repo.Schema do
   end
 
   defp on_conflict(on_conflict, conflict_target,
-                   %{source: {prefix, source}, schema: schema}, changes, adapter) do
+                   %{source: {prefix, source}, schema: schema}, header, counter_fun, adapter) do
     case on_conflict do
       :raise when conflict_target == [] ->
         {:raise, [], []}
@@ -501,20 +502,24 @@ defmodule Ecto.Repo.Schema do
       :nothing ->
         {:nothing, [], conflict_target}
       :replace_all ->
-        {:replace_all, [], conflict_target}
+        {header, [], conflict_target}
+      :replace_all_except_primary_key when is_nil(schema) ->
+        raise ArgumentError, "cannot use :replace_all_except_primary_key on operations without a schema"
+      :replace_all_except_primary_key ->
+        {header -- schema.__schema__(:primary_key), [], conflict_target}
       [_ | _] = on_conflict ->
         from = if schema, do: {source, schema}, else: source
         query = Ecto.Query.from from, update: ^on_conflict
-        on_conflict_query(query, {source, schema}, prefix, changes, adapter, conflict_target)
+        on_conflict_query(query, {source, schema}, prefix, counter_fun, adapter, conflict_target)
       %Ecto.Query{} = query ->
-        on_conflict_query(query, {source, schema}, prefix, changes, adapter, conflict_target)
+        on_conflict_query(query, {source, schema}, prefix, counter_fun, adapter, conflict_target)
       other ->
         raise ArgumentError, "unknown value for :on_conflict, got: #{inspect other}"
     end
   end
 
-  defp on_conflict_query(query, from, prefix, changes, adapter, conflict_target) do
-    counter = changes.()
+  defp on_conflict_query(query, from, prefix, counter_fun, adapter, conflict_target) do
+    counter = counter_fun.()
 
     {query, params, _} =
       %{query | prefix: prefix}
@@ -735,7 +740,7 @@ defmodule Ecto.Repo.Schema do
 
   defp dump_changes!(action, changes, schema, extra, dumper, adapter) do
     autogen = autogenerate_changes(schema, action, changes)
-    dumped  =
+    dumped =
       dump_fields!(action, schema, changes, dumper, adapter) ++
       dump_fields!(action, schema, autogen, dumper, adapter) ++
       extra


Test patch:
diff --git a/integration_test/cases/repo.exs b/integration_test/cases/repo.exs
index d743a9d4..8bbf6e57 100644
--- a/integration_test/cases/repo.exs
+++ b/integration_test/cases/repo.exs
@@ -288,7 +288,7 @@ defmodule Ecto.Integration.RepoTest do
       changeset
       |> Ecto.Changeset.unique_constraint(:uuid)
       |> TestRepo.insert()
-    assert changeset.errors == [uuid: {"has already been taken", []}]
+    assert changeset.errors == [uuid: {"has already been taken", [constraint: :unique]}]
     assert changeset.data.__meta__.state == :built
   end
 
@@ -305,7 +305,7 @@ defmodule Ecto.Integration.RepoTest do
       }
 
     [_, p2, _] = changeset.changes.posts
-    assert p2.errors == [uuid: {"has already been taken", []}]
+    assert p2.errors == [uuid: {"has already been taken", [constraint: :unique]}]
   end
 
   @tag :id_type
@@ -318,7 +318,7 @@ defmodule Ecto.Integration.RepoTest do
       changeset
       |> Ecto.Changeset.unique_constraint(:uuid)
       |> TestRepo.insert()
-    assert changeset.errors == [uuid: {"has already been taken", []}]
+    assert changeset.errors == [uuid: {"has already been taken", [constraint: :unique]}]
     assert changeset.data.__meta__.state == :built
   end
 
@@ -425,7 +425,7 @@ defmodule Ecto.Integration.RepoTest do
       changeset
       |> Ecto.Changeset.foreign_key_constraint(:post_id)
       |> TestRepo.insert()
-    assert changeset.errors == [post_id: {"does not exist", []}]
+    assert changeset.errors == [post_id: {"does not exist", [constraint: :foreign]}]
   end
 
   @tag :foreign_key_constraint
@@ -455,7 +455,7 @@ defmodule Ecto.Integration.RepoTest do
       changeset
       |> Ecto.Changeset.assoc_constraint(:post)
       |> TestRepo.insert()
-    assert changeset.errors == [post: {"does not exist", []}]
+    assert changeset.errors == [post: {"does not exist", [constraint: :assoc]}]
   end
 
   @tag :foreign_key_constraint
@@ -499,7 +499,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", []}]
+    assert changeset.errors == [permalink: {"is still associated with this entry", [constraint: :no_assoc]}]
   end
 
   test "insert and update with failing child foreign key" do
@@ -1185,7 +1185,7 @@ defmodule Ecto.Integration.RepoTest do
     end
 
     test "on conflict ignore" do
-      post = %Post{title: "first", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"}
+      post = %Post{title: "first", uuid: Ecto.UUID.generate()}
       {:ok, inserted} = TestRepo.insert(post, on_conflict: :nothing)
       assert inserted.id
       assert inserted.__meta__.state == :loaded
@@ -1198,7 +1198,7 @@ defmodule Ecto.Integration.RepoTest do
     @tag :with_conflict_target
     test "on conflict and associations" do
       on_conflict = [set: [title: "second"]]
-      post = %Post{uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e",
+      post = %Post{uuid: Ecto.UUID.generate(),
                    title: "first", comments: [%Comment{}]}
       {:ok, inserted} = TestRepo.insert(post, on_conflict: on_conflict, conflict_target: [:uuid])
       assert inserted.id
@@ -1215,7 +1215,7 @@ defmodule Ecto.Integration.RepoTest do
 
     @tag :with_conflict_target
     test "on conflict ignore and conflict target" do
-      post = %Post{title: "first", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"}
+      post = %Post{title: "first", uuid: Ecto.UUID.generate()}
       {:ok, inserted} = TestRepo.insert(post, on_conflict: :nothing, conflict_target: [:uuid])
       assert inserted.id
 
@@ -1230,7 +1230,7 @@ defmodule Ecto.Integration.RepoTest do
     @tag :without_conflict_target
     test "on conflict keyword list" do
       on_conflict = [set: [title: "second"]]
-      post = %Post{title: "first", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"}
+      post = %Post{title: "first", uuid: Ecto.UUID.generate()}
       {:ok, inserted} = TestRepo.insert(post, on_conflict: on_conflict)
       assert inserted.id
 
@@ -1243,7 +1243,7 @@ defmodule Ecto.Integration.RepoTest do
     @tag :with_conflict_target
     test "on conflict keyword list and conflict target" do
       on_conflict = [set: [title: "second"]]
-      post = %Post{title: "first", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"}
+      post = %Post{title: "first", uuid: Ecto.UUID.generate()}
       {:ok, inserted} = TestRepo.insert(post, on_conflict: on_conflict, conflict_target: [:uuid])
       assert inserted.id
 
@@ -1305,7 +1305,7 @@ defmodule Ecto.Integration.RepoTest do
     @tag :returning
     @tag :with_conflict_target
     test "on conflict ignore and returning" do
-      post = %Post{title: "first", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"}
+      post = %Post{title: "first", uuid: Ecto.UUID.generate()}
       {:ok, inserted} = TestRepo.insert(post, on_conflict: :nothing, conflict_target: [:uuid])
       assert inserted.id
 
@@ -1316,7 +1316,7 @@ defmodule Ecto.Integration.RepoTest do
     @tag :without_conflict_target
     test "on conflict query" do
       on_conflict = from Post, update: [set: [title: "second"]]
-      post = %Post{title: "first", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"}
+      post = %Post{title: "first", uuid: Ecto.UUID.generate()}
       {:ok, inserted} = TestRepo.insert(post, on_conflict: on_conflict)
       assert inserted.id
 
@@ -1329,7 +1329,7 @@ defmodule Ecto.Integration.RepoTest do
     @tag :with_conflict_target
     test "on conflict query and conflict target" do
       on_conflict = from Post, update: [set: [title: "second"]]
-      post = %Post{title: "first", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"}
+      post = %Post{title: "first", uuid: Ecto.UUID.generate()}
       {:ok, inserted} = TestRepo.insert(post, on_conflict: on_conflict, conflict_target: [:uuid])
       assert inserted.id
 
@@ -1344,41 +1344,35 @@ defmodule Ecto.Integration.RepoTest do
 
     @tag :without_conflict_target
     test "on conflict replace_all" do
-      post = %Post{title: "first", text: "text", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"}
+      post = %Post{title: "first", text: "text", uuid: Ecto.UUID.generate()}
       {:ok, inserted} = TestRepo.insert(post, on_conflict: :replace_all)
       assert inserted.id
 
-      # Error on non-conflict target
-      post = %Post{id: inserted.id, title: "updated",
-                   text: "updated", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"}
-
-      # Error on conflict target
+      post = %Post{title: "updated", text: "updated", uuid: post.uuid}
       post = TestRepo.insert!(post, on_conflict: :replace_all)
+      assert post.id == inserted.id
       assert post.title == "updated"
       assert post.text == "updated"
 
-      assert TestRepo.all(from p in Post, select: p.title) == ["updated"]
-      assert TestRepo.all(from p in Post, select: p.text) == ["updated"]
+      assert TestRepo.all(from p in Post, select: {p.id, p.title, p.text}) ==
+             [{inserted.id, "updated", "updated"}]
       assert TestRepo.all(from p in Post, select: count(p.id)) == [1]
     end
 
     @tag :with_conflict_target
     test "on conflict replace_all and conflict target" do
-      post = %Post{title: "first", text: "text", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"}
-      {:ok, inserted} = TestRepo.insert(post, on_conflict: :replace_all, conflict_target: :id)
+      post = %Post{title: "first", text: "text", uuid: Ecto.UUID.generate()}
+      {:ok, inserted} = TestRepo.insert(post, on_conflict: :replace_all, conflict_target: :uuid)
       assert inserted.id
 
-      # Error on non-conflict target
-      post = %Post{id: inserted.id, title: "updated",
-                   text: "updated", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"}
-
-      # Error on conflict target
-      post = TestRepo.insert!(post, on_conflict: :replace_all, conflict_target: :id)
+      post = %Post{title: "updated", text: "updated", uuid: post.uuid}
+      post = TestRepo.insert!(post, on_conflict: :replace_all, conflict_target: :uuid)
+      assert post.id == inserted.id
       assert post.title == "updated"
       assert post.text == "updated"
 
-      assert TestRepo.all(from p in Post, select: p.title) == ["updated"]
-      assert TestRepo.all(from p in Post, select: p.text) == ["updated"]
+      assert TestRepo.all(from p in Post, select: {p.id, p.title, p.text}) ==
+             [{inserted.id, "updated", "updated"}]
       assert TestRepo.all(from p in Post, select: count(p.id)) == [1]
     end
   end
@@ -1387,13 +1381,13 @@ defmodule Ecto.Integration.RepoTest do
     @describetag :upsert_all
 
     test "on conflict raise" do
-      post = [title: "first", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"]
+      post = [title: "first", uuid: Ecto.UUID.generate()]
       {1, nil} = TestRepo.insert_all(Post, [post], on_conflict: :raise)
       assert catch_error(TestRepo.insert_all(Post, [post], on_conflict: :raise))
     end
 
     test "on conflict ignore" do
-      post = [title: "first", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"]
+      post = [title: "first", uuid: Ecto.UUID.generate()]
       assert TestRepo.insert_all(Post, [post], on_conflict: :nothing) == {1, nil}
 
       # PG returns 0, MySQL returns 1
@@ -1405,7 +1399,7 @@ defmodule Ecto.Integration.RepoTest do
 
     @tag :with_conflict_target
     test "on conflict ignore and conflict target" do
-      post = [title: "first", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"]
+      post = [title: "first", uuid: Ecto.UUID.generate()]
       assert TestRepo.insert_all(Post, [post], on_conflict: :nothing, conflict_target: [:uuid]) ==
              {1, nil}
 
@@ -1420,7 +1414,7 @@ defmodule Ecto.Integration.RepoTest do
     @tag :with_conflict_target
     test "on conflict keyword list and conflict target" do
       on_conflict = [set: [title: "second"]]
-      post = [title: "first", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"]
+      post = [title: "first", uuid: Ecto.UUID.generate()]
       {1, nil} = TestRepo.insert_all(Post, [post], on_conflict: on_conflict, conflict_target: [:uuid])
 
       # Error on non-conflict target
@@ -1450,7 +1444,7 @@ defmodule Ecto.Integration.RepoTest do
     @tag :with_conflict_target
     test "on conflict query and conflict target" do
       on_conflict = from Post, update: [set: [title: "second"]]
-      post = [title: "first", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"]
+      post = [title: "first", uuid: Ecto.UUID.generate()]
       assert TestRepo.insert_all(Post, [post], on_conflict: on_conflict, conflict_target: [:uuid]) ==
              {1, nil}
 
@@ -1467,7 +1461,7 @@ defmodule Ecto.Integration.RepoTest do
     @tag :with_conflict_target
     test "on conflict query and conflict target and returning" do
       on_conflict = from Post, update: [set: [title: "second"]]
-      post = [title: "first", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"]
+      post = [title: "first", uuid: Ecto.UUID.generate()]
       {1, [%{id: id}]} = TestRepo.insert_all(Post, [post], on_conflict: on_conflict,
                                             conflict_target: [:uuid], returning: [:id])
 
@@ -1484,7 +1478,7 @@ defmodule Ecto.Integration.RepoTest do
     @tag :with_conflict_target
     test "source (without an ecto schema) on conflict query and conflict target" do
       on_conflict = [set: [title: "second"]]
-      {:ok, uuid} = Ecto.UUID.dump("6fa459ea-ee8a-3ca4-894e-db77e160355e")
+      {:ok, uuid} = Ecto.UUID.dump(Ecto.UUID.generate())
       post = [title: "first", uuid: uuid]
       assert TestRepo.insert_all("posts", [post], on_conflict: on_conflict, conflict_target: [:uuid]) ==
              {1, nil}
@@ -1500,61 +1494,122 @@ defmodule Ecto.Integration.RepoTest do
 
     @tag :without_conflict_target
     test "on conflict replace_all" do
-      post_first = %Post{title: "first", public: true}
-      post_second = %Post{title: "second", public: false}
+      post_first = %Post{title: "first", public: true, uuid: Ecto.UUID.generate}
+      post_second = %Post{title: "second", public: false, uuid: Ecto.UUID.generate}
 
-      {:ok, inserted_first} = TestRepo.insert(post_first, on_conflict: :replace_all)
-      {:ok, inserted_second} = TestRepo.insert(post_second, on_conflict: :replace_all)
+      {:ok, post_first} = TestRepo.insert(post_first, on_conflict: :replace_all)
+      {:ok, post_second} = TestRepo.insert(post_second, on_conflict: :replace_all)
 
-      assert inserted_first.id
-      assert inserted_second.id
+      assert post_first.id
+      assert post_second.id
       assert TestRepo.all(from p in Post, select: count(p.id)) == [2]
 
-      # multiple record change value
-      changes = [%{id: inserted_first.id, title: "first_updated", text: "first_updated"},
-                 %{id: inserted_second.id, title: "second_updated", text: "second_updated"}]
+      # Multiple record change value: note IDS are also replaced
+      changes = [%{id: post_first.id + 2, title: "first_updated",
+                   text: "first_updated", uuid: post_first.uuid},
+                 %{id: post_second.id + 2, title: "second_updated",
+                   text: "second_updated", uuid: post_second.uuid}]
 
       TestRepo.insert_all(Post, changes, on_conflict: :replace_all)
-
       assert TestRepo.all(from p in Post, select: count(p.id)) == [2]
 
-      updated_first =  TestRepo.get(Post, inserted_first.id)
+      updated_first = TestRepo.get(Post, post_first.id + 2)
       assert updated_first.title == "first_updated"
       assert updated_first.text == "first_updated"
 
-      updated_first =  TestRepo.get(Post, inserted_second.id)
-      assert updated_first.title == "second_updated"
-      assert updated_first.text == "second_updated"
+      updated_second = TestRepo.get(Post, post_second.id + 2)
+      assert updated_second.title == "second_updated"
+      assert updated_second.text == "second_updated"
     end
 
     @tag :with_conflict_target
     test "on conflict replace_all and conflict_target" do
-      post_first = %Post{title: "first", public: true}
-      post_second = %Post{title: "second", public: false}
+      post_first = %Post{title: "first", public: true, uuid: Ecto.UUID.generate()}
+      post_second = %Post{title: "second", public: false, uuid: Ecto.UUID.generate()}
+
+      {:ok, post_first} = TestRepo.insert(post_first, on_conflict: :replace_all, conflict_target: :uuid)
+      {:ok, post_second} = TestRepo.insert(post_second, on_conflict: :replace_all, conflict_target: :uuid)
 
-      {:ok, inserted_first} = TestRepo.insert(post_first, on_conflict: :replace_all, conflict_target: :id)
-      {:ok, inserted_second} = TestRepo.insert(post_second, on_conflict: :replace_all, conflict_target: :id)
+      assert post_first.id
+      assert post_second.id
+      assert TestRepo.all(from p in Post, select: count(p.id)) == [2]
+
+      # Multiple record change value: note IDS are also replaced
+      changes = [%{id: post_first.id + 2, title: "first_updated",
+                   text: "first_updated", uuid: post_first.uuid},
+                 %{id: post_second.id + 2, title: "second_updated",
+                   text: "second_updated", uuid: post_second.uuid}]
 
-      assert inserted_first.id
-      assert inserted_second.id
+      TestRepo.insert_all(Post, changes, on_conflict: :replace_all, conflict_target: :uuid)
       assert TestRepo.all(from p in Post, select: count(p.id)) == [2]
 
-      # multiple record change value
-      changes = [%{id: inserted_first.id, title: "first_updated", text: "first_updated"},
-                 %{id: inserted_second.id, title: "second_updated", text: "second_updated"}]
+      updated_first = TestRepo.get(Post, post_first.id + 2)
+      assert updated_first.title == "first_updated"
+      assert updated_first.text == "first_updated"
+
+      updated_second = TestRepo.get(Post, post_second.id + 2)
+      assert updated_second.title == "second_updated"
+      assert updated_second.text == "second_updated"
+    end
+
+    @tag :without_conflict_target
+    test "on conflict replace_all_except_primary_key" do
+      post_first = %Post{title: "first", public: true, uuid: Ecto.UUID.generate}
+      post_second = %Post{title: "second", public: false, uuid: Ecto.UUID.generate}
 
-      TestRepo.insert_all(Post, changes, on_conflict: :replace_all, conflict_target: :id)
+      {:ok, post_first} = TestRepo.insert(post_first, on_conflict: :replace_all_except_primary_key)
+      {:ok, post_second} = TestRepo.insert(post_second, on_conflict: :replace_all_except_primary_key)
 
+      assert post_first.id
+      assert post_second.id
       assert TestRepo.all(from p in Post, select: count(p.id)) == [2]
 
-      updated_first =  TestRepo.get(Post, inserted_first.id)
+      # Multiple record change value: note IDS are not replaced
+      changes = [%{id: post_first.id + 2, title: "first_updated",
+                   text: "first_updated", uuid: post_first.uuid},
+                 %{id: post_second.id + 2, title: "second_updated",
+                   text: "second_updated", uuid: post_second.uuid}]
+
+      TestRepo.insert_all(Post, changes, on_conflict: :replace_all_except_primary_key)
+      assert TestRepo.all(from p in Post, select: count(p.id)) == [2]
+
+      updated_first = TestRepo.get(Post, post_first.id)
       assert updated_first.title == "first_updated"
       assert updated_first.text == "first_updated"
 
+      updated_second = TestRepo.get(Post, post_second.id)
+      assert updated_second.title == "second_updated"
+      assert updated_second.text == "second_updated"
+    end
+
+    @tag :with_conflict_target
+    test "on conflict replace_all_except_primary_key and conflict_target" do
+      post_first = %Post{title: "first", public: true, uuid: Ecto.UUID.generate()}
+      post_second = %Post{title: "second", public: false, uuid: Ecto.UUID.generate()}
+
+      {:ok, post_first} = TestRepo.insert(post_first, on_conflict: :replace_all_except_primary_key, conflict_target: :uuid)
+      {:ok, post_second} = TestRepo.insert(post_second, on_conflict: :replace_all_except_primary_key, conflict_target: :uuid)
+
+      assert post_first.id
+      assert post_second.id
+      assert TestRepo.all(from p in Post, select: count(p.id)) == [2]
+
+      # Multiple record change value: note IDS are not replaced
+      changes = [%{id: post_first.id + 2, title: "first_updated",
+                   text: "first_updated", uuid: post_first.uuid},
+                 %{id: post_second.id + 2, title: "second_updated",
+                   text: "second_updated", uuid: post_second.uuid}]
+
+      TestRepo.insert_all(Post, changes, on_conflict: :replace_all_except_primary_key, conflict_target: :uuid)
+      assert TestRepo.all(from p in Post, select: count(p.id)) == [2]
+
+      updated_first = TestRepo.get(Post, post_first.id)
+      assert updated_first.title == "first_updated"
+      assert updated_first.text == "first_updated"
 
-      updated_first =  TestRepo.get(Post, inserted_second.id)
-      assert updated_first.title == "second_updated"
-      assert updated_first.text == "second_updated"
+      updated_second = TestRepo.get(Post, post_second.id)
+      assert updated_second.title == "second_updated"
+      assert updated_second.text == "second_updated"
     end
   end
 end
diff --git a/test/ecto/adapters/mysql_test.exs b/test/ecto/adapters/mysql_test.exs
index 621d4f19..500f9645 100644
--- a/test/ecto/adapters/mysql_test.exs
+++ b/test/ecto/adapters/mysql_test.exs
@@ -523,7 +523,7 @@ defmodule Ecto.Adapters.MySQLTest do
     query = insert(nil, "schema", [:x, :y], [[:x, :y]], {update, [], []}, [])
     assert query == ~s{INSERT INTO `schema` (`x`,`y`) VALUES (?,?) ON DUPLICATE KEY UPDATE `z` = ?}
 
-    query = insert(nil, "schema", [:x, :y], [[:x, :y]], {:replace_all, [], []}, [])
+    query = insert(nil, "schema", [:x, :y], [[:x, :y]], {[:x, :y], [], []}, [])
     assert query == ~s{INSERT INTO `schema` (`x`,`y`) VALUES (?,?) ON DUPLICATE KEY UPDATE `x` = VALUES(`x`),`y` = VALUES(`y`)}
   end
 
diff --git a/test/ecto/adapters/postgres_test.exs b/test/ecto/adapters/postgres_test.exs
index b438a990..a7468aa5 100644
--- a/test/ecto/adapters/postgres_test.exs
+++ b/test/ecto/adapters/postgres_test.exs
@@ -674,13 +674,13 @@ defmodule Ecto.Adapters.PostgresTest do
     assert query == ~s{INSERT INTO "schema" AS s0 ("x","y") VALUES ($1,$2) ON CONFLICT ("x","y") DO UPDATE SET "z" = $3 WHERE (s0."w" = TRUE) RETURNING "z"}
 
     # For :replace_all
-    query = insert(nil, "schema", [:x, :y], [[:x, :y]], {:replace_all, [], [:id]}, [])
+    query = insert(nil, "schema", [:x, :y], [[:x, :y]], {[:x, :y], [], [:id]}, [])
     assert query == ~s{INSERT INTO "schema" ("x","y") VALUES ($1,$2) ON CONFLICT ("id") DO UPDATE SET "x" = EXCLUDED."x","y" = EXCLUDED."y"}
 
-    query = insert(nil, "schema", [:x, :y], [[:x, :y]], {:replace_all, [], []}, [])
+    query = insert(nil, "schema", [:x, :y], [[:x, :y]], {[:x, :y], [], []}, [])
     assert query == ~s{INSERT INTO "schema" ("x","y") VALUES ($1,$2) ON CONFLICT DO UPDATE SET "x" = EXCLUDED."x","y" = EXCLUDED."y"}
 
-    query = insert(nil, "schema", [:x, :y], [[:x, :y]], {:replace_all, [], {:constraint, :foo}}, [])
+    query = insert(nil, "schema", [:x, :y], [[:x, :y]], {[:x, :y], [], {:constraint, :foo}}, [])
     assert query == ~s{INSERT INTO "schema" ("x","y") VALUES ($1,$2) ON CONFLICT ON CONSTRAINT \"foo\" DO UPDATE SET "x" = EXCLUDED."x","y" = EXCLUDED."y"}
   end
 
diff --git a/test/ecto/changeset/belongs_to_test.exs b/test/ecto/changeset/belongs_to_test.exs
index 3a2928b2..b16db7f2 100644
--- a/test/ecto/changeset/belongs_to_test.exs
+++ b/test/ecto/changeset/belongs_to_test.exs
@@ -71,7 +71,7 @@ defmodule Ecto.Changeset.BelongsToTest do
     refute changeset.valid?
 
     changeset = cast(%Author{}, %{"profile" => "value"}, :profile)
-    assert changeset.errors == [profile: {"is invalid", [type: :map]}]
+    assert changeset.errors == [profile: {"is invalid", [validation: :assoc, type: :map]}]
     refute changeset.valid?
   end
 
@@ -249,17 +249,17 @@ defmodule Ecto.Changeset.BelongsToTest do
 
     changeset = cast(schema, %{"invalid_profile" => nil}, :invalid_profile)
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_profile: {"is invalid", [type: :map]}]
+    assert changeset.errors == [invalid_profile: {"is invalid", [validation: :assoc, type: :map]}]
     refute changeset.valid?
 
     changeset = cast(schema, %{"invalid_profile" => %{"id" => 2}}, :invalid_profile)
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_profile: {"is invalid", [type: :map]}]
+    assert changeset.errors == [invalid_profile: {"is invalid", [validation: :assoc, type: :map]}]
     refute changeset.valid?
 
     changeset = cast(schema, %{"invalid_profile" => nil}, :invalid_profile, invalid_message: "a custom message")
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_profile: {"a custom message", [type: :map]}]
+    assert changeset.errors == [invalid_profile: {"a custom message", [validation: :assoc, type: :map]}]
     refute changeset.valid?
   end
 
diff --git a/test/ecto/changeset/embedded_no_pk_test.exs b/test/ecto/changeset/embedded_no_pk_test.exs
index 49c8d76f..fa714f1d 100644
--- a/test/ecto/changeset/embedded_no_pk_test.exs
+++ b/test/ecto/changeset/embedded_no_pk_test.exs
@@ -101,7 +101,7 @@ defmodule Ecto.Changeset.EmbeddedNoPkTest do
     refute changeset.valid?
 
     changeset = cast(%Author{}, %{"profile" => "value"}, :profile, required: true)
-    assert changeset.errors == [profile: {"is invalid", [type: :map]}]
+    assert changeset.errors == [profile: {"is invalid", [validation: :embed, type: :map]}]
     refute changeset.valid?
   end
 
@@ -204,17 +204,17 @@ defmodule Ecto.Changeset.EmbeddedNoPkTest do
 
     changeset = cast(schema, %{"invalid_profile" => nil}, :invalid_profile)
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_profile: {"is invalid", [type: :map]}]
+    assert changeset.errors == [invalid_profile: {"is invalid", [validation: :embed, type: :map]}]
     refute changeset.valid?
 
     changeset = cast(schema, %{"invalid_profile" => %{}}, :invalid_profile)
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_profile: {"is invalid", [type: :map]}]
+    assert changeset.errors == [invalid_profile: {"is invalid", [validation: :embed, type: :map]}]
     refute changeset.valid?
 
     changeset = cast(schema, %{"invalid_profile" => nil}, :invalid_profile, invalid_message: "a custom message")
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_profile: {"a custom message", [type: :map]}]
+    assert changeset.errors == [invalid_profile: {"a custom message", [validation: :embed, type: :map]}]
     refute changeset.valid?
   end
 
@@ -302,15 +302,15 @@ defmodule Ecto.Changeset.EmbeddedNoPkTest do
 
   test "cast embeds_many with invalid params" do
     changeset = cast(%Author{}, %{"posts" => "value"}, :posts)
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [posts: {"is invalid", [validation: :embed, type: {:array, :map}]}]
     refute changeset.valid?
 
     changeset = cast(%Author{}, %{"posts" => ["value"]}, :posts)
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [posts: {"is invalid", [validation: :embed, type: {:array, :map}]}]
     refute changeset.valid?
 
     changeset = cast(%Author{}, %{"posts" => nil}, :posts)
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [posts: {"is invalid", [validation: :embed, type: {:array, :map}]}]
     refute changeset.valid?
   end
 
@@ -332,7 +332,7 @@ defmodule Ecto.Changeset.EmbeddedNoPkTest do
 
     changeset = cast(%Author{posts: []}, %{"posts" => nil}, :posts, required: true)
     assert changeset.changes == %{}
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [posts: {"is invalid", [validation: :embed, type: {:array, :map}]}]
   end
 
   test "cast embeds_many with empty parameters" do
@@ -359,12 +359,12 @@ defmodule Ecto.Changeset.EmbeddedNoPkTest do
 
     changeset = cast(schema, %{"invalid_posts" => []}, :invalid_posts)
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [invalid_posts: {"is invalid", [validation: :embed, type: {:array, :map}]}]
     refute changeset.valid?
 
     changeset = cast(schema, %{"invalid_posts" => [%{}]}, :invalid_posts)
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [invalid_posts: {"is invalid", [validation: :embed, type: {:array, :map}]}]
     refute changeset.valid?
   end
 
diff --git a/test/ecto/changeset/embedded_test.exs b/test/ecto/changeset/embedded_test.exs
index 1477862c..46b6de0b 100644
--- a/test/ecto/changeset/embedded_test.exs
+++ b/test/ecto/changeset/embedded_test.exs
@@ -106,7 +106,7 @@ defmodule Ecto.Changeset.EmbeddedTest do
     refute changeset.valid?
 
     changeset = cast(%Author{}, %{"profile" => "value"}, :profile, required: true)
-    assert changeset.errors == [profile: {"is invalid", [type: :map]}]
+    assert changeset.errors == [profile: {"is invalid", [validation: :embed, type: :map]}]
     refute changeset.valid?
   end
 
@@ -267,17 +267,17 @@ defmodule Ecto.Changeset.EmbeddedTest do
 
     changeset = cast(schema, %{"invalid_profile" => nil}, :invalid_profile)
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_profile: {"is invalid", [type: :map]}]
+    assert changeset.errors == [invalid_profile: {"is invalid", [validation: :embed, type: :map]}]
     refute changeset.valid?
 
     changeset = cast(schema, %{"invalid_profile" => %{"id" => 2}}, :invalid_profile)
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_profile: {"is invalid", [type: :map]}]
+    assert changeset.errors == [invalid_profile: {"is invalid", [validation: :embed, type: :map]}]
     refute changeset.valid?
 
     changeset = cast(schema, %{"invalid_profile" => nil}, :invalid_profile, invalid_message: "a custom message")
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_profile: {"a custom message", [type: :map]}]
+    assert changeset.errors == [invalid_profile: {"a custom message", [validation: :embed, type: :map]}]
     refute changeset.valid?
   end
 
@@ -391,19 +391,19 @@ defmodule Ecto.Changeset.EmbeddedTest do
 
   test "cast embeds_many with invalid params" do
     changeset = cast(%Author{}, %{"posts" => "value"}, :posts)
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [posts: {"is invalid", [validation: :embed, type: {:array, :map}]}]
     refute changeset.valid?
 
     changeset = cast(%Author{}, %{"posts" => ["value"]}, :posts)
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [posts: {"is invalid", [validation: :embed, type: {:array, :map}]}]
     refute changeset.valid?
 
     changeset = cast(%Author{}, %{"posts" => nil}, :posts)
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [posts: {"is invalid", [validation: :embed, type: {:array, :map}]}]
     refute changeset.valid?
 
     changeset = cast(%Author{}, %{"posts" => %{"id" => "invalid"}}, :posts)
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [posts: {"is invalid", [validation: :embed, type: {:array, :map}]}]
     refute changeset.valid?
   end
 
@@ -439,7 +439,7 @@ defmodule Ecto.Changeset.EmbeddedTest do
 
     changeset = cast(%Author{posts: []}, %{"posts" => nil}, :posts, required: true)
     assert changeset.changes == %{}
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [posts: {"is invalid", [validation: :embed, type: {:array, :map}]}]
   end
 
   test "cast embeds_many with empty parameters" do
@@ -466,12 +466,12 @@ defmodule Ecto.Changeset.EmbeddedTest do
 
     changeset = cast(schema, %{"invalid_posts" => []}, :invalid_posts)
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [invalid_posts: {"is invalid", [validation: :embed, type: {:array, :map}]}]
     refute changeset.valid?
 
     changeset = cast(schema, %{"invalid_posts" => [%{"id" => 2}]}, :invalid_posts)
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [invalid_posts: {"is invalid", [validation: :embed, type: {:array, :map}]}]
     refute changeset.valid?
   end
 
@@ -844,8 +844,8 @@ defmodule Ecto.Changeset.EmbeddedTest do
     assert Changeset.traverse_errors(changeset, &(&1)) == %{posts: [{"can't be blank", [validation: :required]}]}
 
     changeset = cast(%Author{posts: []}, %{"posts" => nil}, :posts, required: true)
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
-    assert Changeset.traverse_errors(changeset, &(&1)) == %{posts: [{"is invalid", [type: {:array, :map}]}]}
+    assert changeset.errors == [posts: {"is invalid", [validation: :embed, type: {:array, :map}]}]
+    assert Changeset.traverse_errors(changeset, &(&1)) == %{posts: [{"is invalid", [validation: :embed, type: {:array, :map}]}]}
 
     changeset = cast(%Author{posts: []}, %{"posts" => [%{title: nil}]}, :posts, required: true)
     assert changeset.errors == []
diff --git a/test/ecto/changeset/has_assoc_test.exs b/test/ecto/changeset/has_assoc_test.exs
index 5b8bb8df..02e8ea50 100644
--- a/test/ecto/changeset/has_assoc_test.exs
+++ b/test/ecto/changeset/has_assoc_test.exs
@@ -96,7 +96,7 @@ defmodule Ecto.Changeset.HasAssocTest do
     refute changeset.valid?
 
     changeset = cast(%Author{}, %{"profile" => "value"}, :profile)
-    assert changeset.errors == [profile: {"is invalid", [type: :map]}]
+    assert changeset.errors == [profile: {"is invalid", [validation: :assoc, type: :map]}]
     refute changeset.valid?
   end
 
@@ -287,17 +287,17 @@ defmodule Ecto.Changeset.HasAssocTest do
 
     changeset = cast(schema, %{"invalid_profile" => nil}, :invalid_profile)
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_profile: {"is invalid", [type: :map]}]
+    assert changeset.errors == [invalid_profile: {"is invalid", [validation: :assoc, type: :map]}]
     refute changeset.valid?
 
     changeset = cast(schema, %{"invalid_profile" => %{"id" => 2}}, :invalid_profile)
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_profile: {"is invalid", [type: :map]}]
+    assert changeset.errors == [invalid_profile: {"is invalid", [validation: :assoc, type: :map]}]
     refute changeset.valid?
 
     changeset = cast(schema, %{"invalid_profile" => nil}, :invalid_profile, invalid_message: "a custom message")
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_profile: {"a custom message", [type: :map]}]
+    assert changeset.errors == [invalid_profile: {"a custom message", [validation: :assoc, type: :map]}]
     refute changeset.valid?
   end
 
@@ -417,19 +417,19 @@ defmodule Ecto.Changeset.HasAssocTest do
 
   test "cast has_many with invalid params" do
     changeset = cast(%Author{}, %{"posts" => "value"}, :posts)
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [posts: {"is invalid", [validation: :assoc, type: {:array, :map}]}]
     refute changeset.valid?
 
     changeset = cast(%Author{}, %{"posts" => ["value"]}, :posts)
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [posts: {"is invalid", [validation: :assoc, type: {:array, :map}]}]
     refute changeset.valid?
 
     changeset = cast(%Author{}, %{"posts" => nil}, :posts)
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [posts: {"is invalid", [validation: :assoc, type: {:array, :map}]}]
     refute changeset.valid?
 
     changeset = cast(%Author{}, %{"posts" => %{"id" => "invalid"}}, :posts)
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [posts: {"is invalid", [validation: :assoc, type: {:array, :map}]}]
     refute changeset.valid?
   end
 
@@ -476,10 +476,15 @@ defmodule Ecto.Changeset.HasAssocTest do
     assert changeset.changes == %{}
     assert changeset.errors == [posts: {"can't be blank", [validation: :required]}]
 
+    changeset = cast(%Author{posts: [%Post{title: "hello", id: 1}]}, %{}, :posts, required: true)
+    assert changeset.required == [:posts]
+    assert changeset.changes == %{}
+    assert changeset.errors == []
+
     changeset = cast(%Author{posts: []}, %{"posts" => nil}, :posts, required: true)
     assert changeset.required == [:posts]
     assert changeset.changes == %{}
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [posts: {"is invalid", [validation: :assoc, type: {:array, :map}]}]
   end
 
   test "cast has_many with empty parameters" do
@@ -509,12 +514,12 @@ defmodule Ecto.Changeset.HasAssocTest do
 
     changeset = cast(schema, %{"invalid_posts" => []}, :invalid_posts)
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [invalid_posts: {"is invalid", [validation: :assoc, type: {:array, :map}]}]
     refute changeset.valid?
 
     changeset = cast(schema, %{"invalid_posts" => [%{"id" => 2}]}, :invalid_posts)
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [invalid_posts: {"is invalid", [validation: :assoc, type: {:array, :map}]}]
     refute changeset.valid?
   end
 
@@ -1061,8 +1066,8 @@ defmodule Ecto.Changeset.HasAssocTest do
     assert Changeset.traverse_errors(changeset, &(&1)) == %{posts: [{"can't be blank", [validation: :required]}]}
 
     changeset = cast(%Author{posts: []}, %{"posts" => nil}, :posts, required: true)
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
-    assert Changeset.traverse_errors(changeset, &(&1)) == %{posts: [{"is invalid", [type: {:array, :map}]}]}
+    assert changeset.errors == [posts: {"is invalid", [validation: :assoc, type: {:array, :map}]}]
+    assert Changeset.traverse_errors(changeset, &(&1)) == %{posts: [{"is invalid", [validation: :assoc, type: {:array, :map}]}]}
 
     changeset = cast(%Author{}, %{posts: []}, :posts, required: true)
     assert changeset.errors == [posts: {"can't be blank", [validation: :required]}]
diff --git a/test/ecto/changeset/many_to_many_test.exs b/test/ecto/changeset/many_to_many_test.exs
index 52de75c7..fdb11b1c 100644
--- a/test/ecto/changeset/many_to_many_test.exs
+++ b/test/ecto/changeset/many_to_many_test.exs
@@ -115,19 +115,19 @@ defmodule Ecto.Changeset.ManyToManyTest do
 
   test "cast many_to_many with invalid params" do
     changeset = cast(%Author{}, %{"posts" => "value"}, :posts)
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [posts: {"is invalid", [validation: :assoc, type: {:array, :map}]}]
     refute changeset.valid?
 
     changeset = cast(%Author{}, %{"posts" => ["value"]}, :posts)
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [posts: {"is invalid", [validation: :assoc, type: {:array, :map}]}]
     refute changeset.valid?
 
     changeset = cast(%Author{}, %{"posts" => nil}, :posts)
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [posts: {"is invalid", [validation: :assoc, type: {:array, :map}]}]
     refute changeset.valid?
 
     changeset = cast(%Author{}, %{"posts" => %{"id" => "invalid"}}, :posts)
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [posts: {"is invalid", [validation: :assoc, type: {:array, :map}]}]
     refute changeset.valid?
   end
 
@@ -174,7 +174,7 @@ defmodule Ecto.Changeset.ManyToManyTest do
     changeset = cast(%Author{posts: []}, %{"posts" => nil}, :posts, required: true)
     assert changeset.required == [:posts]
     assert changeset.changes == %{}
-    assert changeset.errors == [posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [posts: {"is invalid", [validation: :assoc, type: {:array, :map}]}]
   end
 
   test "cast many_to_many with empty parameters" do
@@ -204,17 +204,17 @@ defmodule Ecto.Changeset.ManyToManyTest do
 
     changeset = cast(schema, %{"invalid_posts" => []}, :invalid_posts)
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [invalid_posts: {"is invalid", [validation: :assoc, type: {:array, :map}]}]
     refute changeset.valid?
 
     changeset = cast(schema, %{"invalid_posts" => [%{"id" => 2}]}, :invalid_posts)
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_posts: {"is invalid", [type: {:array, :map}]}]
+    assert changeset.errors == [invalid_posts: {"is invalid", [validation: :assoc, type: {:array, :map}]}]
     refute changeset.valid?
 
     changeset = cast(schema, %{"invalid_posts" => [%{"id" => 2}]}, :invalid_posts, invalid_message: "a custom message")
     assert changeset.changes == %{}
-    assert changeset.errors == [invalid_posts: {"a custom message", [type: {:array, :map}]}]
+    assert changeset.errors == [invalid_posts: {"a custom message", [validation: :assoc, type: {:array, :map}]}]
     refute changeset.valid?
   end
 
diff --git a/test/ecto/changeset_test.exs b/test/ecto/changeset_test.exs
index d3174666..bbf26950 100644
--- a/test/ecto/changeset_test.exs
+++ b/test/ecto/changeset_test.exs
@@ -820,18 +820,18 @@ defmodule Ecto.ChangesetTest do
 
     changeset = changeset(%{"title" => "world"}) |> validate_length(:title, min: 6)
     refute changeset.valid?
-    assert changeset.errors == [title: {"should be at least %{count} character(s)", count: 6, validation: :length, min: 6}]
+    assert changeset.errors == [title: {"should be at least %{count} character(s)", count: 6, validation: :length, kind: :min}]
 
     changeset = changeset(%{"title" => "world"}) |> validate_length(:title, max: 4)
     refute changeset.valid?
-    assert changeset.errors == [title: {"should be at most %{count} character(s)", count: 4, validation: :length, max: 4}]
+    assert changeset.errors == [title: {"should be at most %{count} character(s)", count: 4, validation: :length, kind: :max}]
 
     changeset = changeset(%{"title" => "world"}) |> validate_length(:title, is: 10)
     refute changeset.valid?
-    assert changeset.errors == [title: {"should be %{count} character(s)", count: 10, validation: :length, is: 10}]
+    assert changeset.errors == [title: {"should be %{count} character(s)", count: 10, validation: :length, kind: :is}]
 
     changeset = changeset(%{"title" => "world"}) |> validate_length(:title, is: 10, message: "yada")
-    assert changeset.errors == [title: {"yada", count: 10, validation: :length, is: 10}]
+    assert changeset.errors == [title: {"yada", count: 10, validation: :length, kind: :is}]
   end
 
   test "validate_length/3 with list" do
@@ -848,18 +848,18 @@ defmodule Ecto.ChangesetTest do
 
     changeset = changeset(%{"topics" => ["Politics", "Security"]}) |> validate_length(:topics, min: 6, foo: true)
     refute changeset.valid?
-    assert changeset.errors == [topics: {"should have at least %{count} item(s)", count: 6, validation: :length, min: 6}]
+    assert changeset.errors == [topics: {"should have at least %{count} item(s)", count: 6, validation: :length, kind: :min}]
 
     changeset = changeset(%{"topics" => ["Politics", "Security", "Economy"]}) |> validate_length(:topics, max: 2)
     refute changeset.valid?
-    assert changeset.errors == [topics: {"should have at most %{count} item(s)", count: 2, validation: :length, max: 2}]
+    assert changeset.errors == [topics: {"should have at most %{count} item(s)", count: 2, validation: :length, kind: :max}]
 
     changeset = changeset(%{"topics" => ["Politics", "Security"]}) |> validate_length(:topics, is: 10)
     refute changeset.valid?
-    assert changeset.errors == [topics: {"should have %{count} item(s)", count: 10, validation: :length, is: 10}]
+    assert changeset.errors == [topics: {"should have %{count} item(s)", count: 10, validation: :length, kind: :is}]
 
     changeset = changeset(%{"topics" => ["Politics", "Security"]}) |> validate_length(:topics, is: 10, message: "yada")
-    assert changeset.errors == [topics: {"yada", count: 10, validation: :length, is: 10}]
+    assert changeset.errors == [topics: {"yada", count: 10, validation: :length, kind: :is}]
   end
 
   test "validate_number/3" do
@@ -873,7 +873,7 @@ defmodule Ecto.ChangesetTest do
     changeset = changeset(%{"upvotes" => -1})
                 |> validate_number(:upvotes, greater_than: 0)
     refute changeset.valid?
-    assert changeset.errors == [upvotes: {"must be greater than %{number}", validation: :number, number: 0}]
+    assert changeset.errors == [upvotes: {"must be greater than %{number}", validation: :number, kind: :greater_than, number: 0}]
     assert changeset.validations == [upvotes: {:number, [greater_than: 0]}]
 
     # Multiple validations
@@ -887,12 +887,12 @@ defmodule Ecto.ChangesetTest do
     changeset = changeset(%{"upvotes" => 3})
                 |> validate_number(:upvotes, greater_than: 100, less_than: 0)
     refute changeset.valid?
-    assert changeset.errors == [upvotes: {"must be greater than %{number}", validation: :number, number: 100}]
+    assert changeset.errors == [upvotes: {"must be greater than %{number}", validation: :number, kind: :greater_than, number: 100}]
 
     # Multiple validations with custom message errors
     changeset = changeset(%{"upvotes" => 3})
                 |> validate_number(:upvotes, greater_than: 100, less_than: 0, message: "yada")
-    assert changeset.errors == [upvotes: {"yada", validation: :number, number: 100}]
+    assert changeset.errors == [upvotes: {"yada", validation: :number, kind: :greater_than, number: 100}]
   end
 
   test "validate_number/3 with decimal" do
@@ -1133,19 +1133,19 @@ defmodule Ecto.ChangesetTest do
 
     assert changeset.constraints ==
            [%{type: :unique, field: :title, constraint: "posts_title_index", match: :exact,
-              error: {"has already been taken", []}}]
+              error: {"has already been taken", [constraint: :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", []}}]
+           [%{type: :unique, field: :title, constraint: "whatever", match: :exact, error: {"is taken", [constraint: :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", []}}]
+           [%{type: :unique, field: :title, constraint: "whatever", match: :suffix, error: {"is taken", [constraint: :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", []}}]
+           [%{type: :unique, field: :title, constraint: "whatever", match: :prefix, error: {"is taken", [constraint: :unique]}}]
 
     assert_raise ArgumentError, ~r/invalid match type: :invalid/, fn ->
       change(%Post{}) |> unique_constraint(:title, name: :whatever, match: :invalid, message: "is taken")
@@ -1157,15 +1157,15 @@ defmodule Ecto.ChangesetTest do
 
     assert changeset.constraints ==
            [%{type: :unique, field: :permalink, constraint: "posts_url_index", match: :exact,
-              error: {"has already been taken", []}}]
+              error: {"has already been taken", [constraint: :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", []}}]
+           [%{type: :unique, field: :permalink, constraint: "whatever", match: :exact, error: {"is taken", [constraint: :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", []}}]
+           [%{type: :unique, field: :permalink, constraint: "whatever", match: :suffix, error: {"is taken", [constraint: :unique]}}]
 
     assert_raise ArgumentError, ~r/invalid match type: :invalid/, fn ->
       change(%Post{}) |> unique_constraint(:permalink, name: :whatever, match: :invalid, message: "is taken")
@@ -1176,44 +1176,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", []}}]
+              error: {"does not exist", [constraint: :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", []}}]
+           [%{type: :foreign_key, field: :post_id, constraint: "whatever", match: :exact, error: {"is not available", [constraint: :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", []}}]
+              error: {"does not exist", [constraint: :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", []}}]
+           [%{type: :foreign_key, field: :permalink, constraint: "whatever", match: :exact, error: {"is not available", [constraint: :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", []}}]
+              error: {"does not exist", [constraint: :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", []}}]
+           [%{type: :foreign_key, field: :post, constraint: "whatever", match: :exact, error: {"is not available", [constraint: :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", []}}]
+              error: {"does not exist", [constraint: :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", []}}]
+           [%{type: :foreign_key, field: :category, constraint: "whatever", match: :exact, error: {"is not available", [constraint: :assoc]}}]
   end
 
   test "assoc_constraint/3 with errors" do
@@ -1232,24 +1232,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", []}}]
+              error: {"are still associated with this entry", [constraint: :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", []}}]
+              error: {"exists", [constraint: :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", []}}]
+              error: {"is still associated with this entry", [constraint: :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", []}}]
+              error: {"exists", [constraint: :no_assoc]}}]
   end
 
   test "no_assoc_constraint/3 with errors" do
@@ -1321,8 +1321,8 @@ defmodule Ecto.ChangesetTest do
       %Ecto.Changeset{}, field, {_, [name: "your title"]} ->
         "value in #{field} is taken"
         |> String.upcase()
-      %Ecto.Changeset{}, field, {_, [count: 3, validation: :length, min: 3] = keys} ->
-        "should be at least #{keys[:min]} character(s) in field #{field}"
+      %Ecto.Changeset{}, field, {_, [count: 3, validation: :length, kind: :min] = keys} ->
+        "should be at least #{keys[:count]} character(s) in field #{field}"
         |> String.upcase()
       %Ecto.Changeset{validations: validations}, field, {_, [validation: :format]} ->
         validation = Keyword.get_values(validations, field)
diff --git a/test/ecto/multi_test.exs b/test/ecto/multi_test.exs
index a7d33d3e..83bcc9b7 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", []}]
+      assert error.errors == [x: {"has already been taken", [constraint: :unique]}]
       refute Map.has_key?(changes, :update)
     end