Repository: elixir-ecto/ecto
Pull request #4134 (2023-03-26 15:55:18)

Allow user to provide custom error messages to `Ecto.Changeset.cast/4`
I was reading Elixir Forum today and came across this: https://elixirforum.com/t/how-do-you-provide-custom-error-message-for-ectos-cast-errors/54837.

```
If you provide a string to an :integer field you get
errors: [count: {"is invalid", [type: :integer, validation: :cast]}]
in a changeset, which is not helpful for a user.
```

It seems like a valid request IMO so I created this PR for feedback.

The implementation I chose is to have an option to define messages for whichever fields you want. Some other methods I thought of and discarded:

- Providing a function to produce the error message. It's a bit more error-prone for the user because they have to define their own default instead of relying on Ecto to provide the default. Also you can define your own function and use it to create the option in this implementation.
- Passing ` :field | {:field, error_msg}` into the cast function instead of using an option. It seems a bit messier/weird compared to existing APIs.


josevalim: Thanks for bringing it up @greg-rychlewski!

Honestly, I am not sure. It feels like we have enough options to tackle this:

1. You can do post processing
2. You can provide custom message with custom types

Thoughts?
josevalim: If we want to provide this, then perhaps we allow an error generation function to be given? We give it the metadata and it returns the message? But then, I am not sure how well it would work with errors from custom types.
greg-rychlewski: >But then, I am not sure how well it would work with errors from custom types.

I was thinking similarly...not sure how easy it is for the user to catch all the possible cases.

> You can do post processing

Yeah tbh I was seeing this as a convenience over the post-processing route. Similar to the other functions but more awkward because this function takes several fields.

I'm pretty much on the fence...I can see its use but also can see it's an unusual/awkward option.

v0idpwn: In one side, I think that is weird that we return an error **string** and don't provide a way to customize it. Feels a bit inconsistent with validations. In the other hand, `Ecto.Changeset.traverse_errors/2` already exists and gives you more flexibility.
josevalim: Another downside of this approach is that if you want to change all messages, you would need to list all fields you are passing in. So I think a more general approach would be better but then a general approach is not much better than traversing it after the fact, right? So I am really unsure. 
greg-rychlewski: What about a function that takes in just the field name and then in the docs put a warning that if their function fails for any of the casted fields it will error? Then you could do both...

```elixir
defp cast_errors(_), do: "is not castable"

@cast_errors [
  field1: "field 1 error",
  field2: "field 2 error"
]

defp cast_errors(key) do
   @cast_errors[key]
end
```
josevalim: Maybe the metadata as well? Is it also called for cast errors from custom types?
greg-rychlewski: I think it makes sense to let them overwrite custom types as well if they don't like the message.

By metadata do you mean the type information?
josevalim: metadata is the additional keyword list with the error message!
greg-rychlewski: Ah I understand now. What do you think about this?
greg-rychlewski: They probably don't need the `:validation` key in the metadata but I thought adding the `:type` helps in case they want to generalize...i.e. all `:string` have this message. And then the old message as like a fallback.
greg-rychlewski: And then I guess the debate is: is this worth it or just do something similar in `traverse_errors`? It could be nice to have this alongside your schema so it just works™. Maybe you are calling `traverse_errors` differently in a few places and need to remember to format these errors properly everywhere.

Patch:
diff --git a/lib/ecto/changeset.ex b/lib/ecto/changeset.ex
index 7fb6fb0c..8c7d7b3c 100644
--- a/lib/ecto/changeset.ex
+++ b/lib/ecto/changeset.ex
@@ -560,6 +560,13 @@ defmodule Ecto.Changeset do
     * `:force_changes` - a boolean indicating whether to include values that don't alter
       the current data in `:changes`. Defaults to `false`
 
+    * `:message` - a function of arity 2 that is used to create the error message when
+      casting fails. It is called for every field that cannot be casted and receives the
+      field name as the first argument and the error metadata as the second argument. It
+      must return a string or `nil`. If a string is returned it will be used as the error
+      message. If `nil` is returned the default error message will be used. The field type
+      is given under the `:type` key in the metadata
+
   ## Examples
 
       iex> changeset = cast(post, params, [:title])
@@ -604,6 +611,18 @@ defmodule Ecto.Changeset do
       iex> changeset.params
       %{}
 
+  You can define a custom error message function.
+
+      iex> params = %{title: 1, body: 2}
+      iex> custom_errors = [title: "must be a string"]
+      iex> msg_func = fn field, _meta -> custom_errors[field] end
+      iex> changeset = cast(post, params, [:title, :body], message: msg_func)
+      iex> changeset.errors
+      [
+        title: {"must be a string", [type: :string, validation: :cast]},
+        body: {"is_invalid", [type: :string, validation: :cast]}
+      ]
+
   ## Composing casts
 
   `cast/4` also accepts a changeset as its first argument. In such cases, all
@@ -633,6 +652,7 @@ defmodule Ecto.Changeset do
 
   def cast(%Changeset{changes: changes, data: data, types: types, empty_values: empty_values} = changeset,
                       params, permitted, opts) do
+
     opts =
       cond do
         opts[:empty_values] ->
@@ -669,6 +689,12 @@ defmodule Ecto.Changeset do
     empty_values = Keyword.get(opts, :empty_values, @empty_values)
     force? = Keyword.get(opts, :force_changes, false)
     params = convert_params(params)
+    msg_func = Keyword.get(opts, :message, fn _, _ -> nil end)
+
+    unless is_function(msg_func, 2) do
+      raise ArgumentError,
+            "expected `:message` to be a function of arity 2, received: #{inspect(msg_func)}"
+    end
 
     defaults = case data do
       %{__struct__: struct} -> struct.__struct__()
@@ -677,7 +703,7 @@ defmodule Ecto.Changeset do
 
     {changes, errors, valid?} =
       Enum.reduce(permitted, {changes, [], true},
-                  &process_param(&1, params, types, data, empty_values, defaults, force?, &2))
+                  &process_param(&1, params, types, data, empty_values, defaults, force?, msg_func, &2))
 
     %Changeset{params: params, data: data, valid?: valid?,
                errors: Enum.reverse(errors), changes: changes, types: types}
@@ -688,7 +714,7 @@ defmodule Ecto.Changeset do
                           message: "expected params to be a :map, got: `#{inspect params}`"
   end
 
-  defp process_param(key, params, types, data, empty_values, defaults, force?, {changes, errors, valid?}) do
+  defp process_param(key, params, types, data, empty_values, defaults, force?, msg_func, {changes, errors, valid?}) do
     {key, param_key} = cast_key(key)
     type = cast_type!(types, key)
 
@@ -704,12 +730,19 @@ defmodule Ecto.Changeset do
       :missing ->
         {changes, errors, valid?}
       {:invalid, custom_errors} ->
-        {message, new_errors} =
+        {default_message, metadata} =
           custom_errors
           |> Keyword.put_new(:validation, :cast)
           |> Keyword.put(:type, type)
           |> Keyword.pop(:message, "is invalid")
-        {changes, [{key, {message, new_errors}} | errors], false}
+
+        message =
+          case msg_func.(key, metadata) do
+            nil -> default_message
+            user_message -> user_message
+          end
+
+        {changes, [{key, {message, metadata}} | errors], false}
     end
   end
 
diff --git a/lib/ecto/schema.ex b/lib/ecto/schema.ex
index e83c5279..c49c15ce 100644
--- a/lib/ecto/schema.ex
+++ b/lib/ecto/schema.ex
@@ -503,13 +503,13 @@ defmodule Ecto.Schema do
     :default,
     :source,
     :autogenerate,
-    :read_after_writes, 
-    :virtual, 
+    :read_after_writes,
+    :virtual,
     :primary_key,
     :load_in_query,
     :redact,
     :foreign_key,
-    :on_replace, 
+    :on_replace,
     :defaults,
     :type,
     :where,
@@ -683,11 +683,11 @@ defmodule Ecto.Schema do
       The default value is calculated at compilation time, so don't use
       expressions like `DateTime.utc_now` or `Ecto.UUID.generate` as
       they would then be the same for all records: in this scenario you can use
-      the `:autogenerate` option to generate at insertion time. 
+      the `:autogenerate` option to generate at insertion time.
 
       The default value is validated against the field's type at compilation time
-      and it will raise an ArgumentError if there is a type mismatch. If you cannot 
-      infer  the field's type at compilation time, you can use the 
+      and it will raise an ArgumentError if there is a type mismatch. If you cannot
+      infer  the field's type at compilation time, you can use the
       `:skip_default_validation` option on the field to skip validations.
 
       Once a default value is set, if you send changes to the changeset that
@@ -757,30 +757,7 @@ defmodule Ecto.Schema do
   """
   defmacro timestamps(opts \\ []) do
     quote bind_quoted: binding() do
-      timestamps = Keyword.merge(@timestamps_opts, opts)
-
-      type = Keyword.get(timestamps, :type, :naive_datetime)
-      autogen = timestamps[:autogenerate] || {Ecto.Schema, :__timestamps__, [type]}
-
-      inserted_at = Keyword.get(timestamps, :inserted_at, :inserted_at)
-      updated_at = Keyword.get(timestamps, :updated_at, :updated_at)
-
-      if inserted_at do
-        opts = if source = timestamps[:inserted_at_source], do: [source: source], else: []
-        Ecto.Schema.field(inserted_at, type, opts)
-      end
-
-      if updated_at do
-        opts = if source = timestamps[:updated_at_source], do: [source: source], else: []
-        Ecto.Schema.field(updated_at, type, opts)
-        Module.put_attribute(__MODULE__, :ecto_autoupdate, {[updated_at], autogen})
-      end
-
-      with [_ | _] = fields <- Enum.filter([inserted_at, updated_at], & &1) do
-        Module.put_attribute(__MODULE__, :ecto_autogenerate, {fields, autogen})
-      end
-
-      :ok
+      Ecto.Schema.__define_timestamps__(__MODULE__, Keyword.merge(@timestamps_opts, opts))
     end
   end
 
@@ -2002,6 +1979,32 @@ defmodule Ecto.Schema do
     end
   end
 
+  @doc false
+  def __define_timestamps__(mod, timestamps) do
+    type = Keyword.get(timestamps, :type, :naive_datetime)
+    autogen = timestamps[:autogenerate] || {Ecto.Schema, :__timestamps__, [type]}
+
+    inserted_at = Keyword.get(timestamps, :inserted_at, :inserted_at)
+    updated_at = Keyword.get(timestamps, :updated_at, :updated_at)
+
+    if inserted_at do
+      opts = if source = timestamps[:inserted_at_source], do: [source: source], else: []
+      Ecto.Schema.__field__(mod, inserted_at, type, opts)
+    end
+
+    if updated_at do
+      opts = if source = timestamps[:updated_at_source], do: [source: source], else: []
+      Ecto.Schema.__field__(mod, updated_at, type, opts)
+      Module.put_attribute(mod, :ecto_autoupdate, {[updated_at], autogen})
+    end
+
+    with [_ | _] = fields <- Enum.filter([inserted_at, updated_at], & &1) do
+      Module.put_attribute(mod, :ecto_autogenerate, {fields, autogen})
+    end
+
+    :ok
+  end
+
   @valid_has_options [:foreign_key, :references, :through, :on_delete, :defaults, :on_replace, :where, :preload_order]
 
   @doc false


Test patch:
diff --git a/test/ecto/changeset_test.exs b/test/ecto/changeset_test.exs
index 2c3e032d..2c5c1cab 100644
--- a/test/ecto/changeset_test.exs
+++ b/test/ecto/changeset_test.exs
@@ -366,6 +366,47 @@ defmodule Ecto.ChangesetTest do
     refute changeset.valid?
   end
 
+  test "cast/4: user-defined error message overrides default invalid error message" do
+    params = %{"title" => 1, "body" => 2}
+    struct = %Post{}
+    custom_errors = [title: "must be a string"]
+    msg_func = fn field, _ -> custom_errors[field] end
+
+    changeset = cast(struct, params, ~w(title body)a, message: msg_func)
+    assert changeset.changes == %{}
+
+    assert changeset.errors == [
+             title: {"must be a string", [type: :string, validation: :cast]},
+             body: {"is invalid", [type: :string, validation: :cast]}
+           ]
+
+    refute changeset.valid?
+  end
+
+  test "cast/4: user-defined error message overrides custom invalid error message" do
+    params = %{"custom_error" => :error}
+    struct = %CustomErrorTest{}
+    msg_func = fn _, _ -> "user-defined error" end
+
+    changeset = cast(struct, params, ~w(custom_error)a, message: msg_func)
+
+    assert changeset.errors == [custom_error: {"user-defined error", [type: Ecto.ChangesetTest.CustomError, validation: :cast, reason: :foobar]}]
+    refute changeset.valid?
+  end
+
+  test "cast/4: :message must be a function of arity 2" do
+    params = %{"title" => 1, "body" => 2}
+    struct = %Post{}
+    custom_errors = [title: "must be a string"]
+    msg_func = fn field -> custom_errors[field] end
+
+    msg = ~r/expected `:message` to be a function of arity 2/
+
+    assert_raise ArgumentError, msg, fn ->
+      cast(struct, params, ~w(title body)a, message: msg_func)
+    end
+  end
+
   test "cast/4: ignores the :type parameter in custom errors" do
     params = %{"custom_error_with_type" => :error}
     struct = %CustomErrorTest{}