Repository: elixir-ecto/ecto
Pull request #4391 (2024-03-07 15:18:15)

Add error reason to `Ecto.Enum.cast/2`
The current behaviour of `Ecto.Enum.cast/2` is that only an `:error` atom is returned in case of error:

```elixir
params = Ecto.Enum.init(values: [foo: 1, bar: 2, baz: 5])

assert :error = Ecto.Enum.cast("qux", params)
```

This PR changes the assertion above to be:

```elixir
assert {:error, validation: :inclusion, enum: [:foo, "foo", 1, :bar, "bar", 2, :baz, "baz", 5]} =
               Ecto.Enum.cast("qux", params)
```

Where I chose to mimic the same `:validation` and `:enum` fields that Ecto emits for the [`Changeset.validate_inclusion/2`](https://hexdocs.pm/ecto/Ecto.Changeset.html#validate_inclusion/4) function.

Also, for this purpose I also defined a new `Ecto.Enum.cast_values/1` function that returns all the possible values that the `cast/2` function accepts — so `[:foo, 1, :bar, 2, :baz, 5]` for this example.


Patch:
diff --git a/lib/ecto/enum.ex b/lib/ecto/enum.ex
index 236475f3..5f2f7c8e 100644
--- a/lib/ecto/enum.ex
+++ b/lib/ecto/enum.ex
@@ -162,10 +162,17 @@ defmodule Ecto.Enum do
 
   def cast(data, params) do
     case params do
-      %{on_load: %{^data => as_atom}} -> {:ok, as_atom}
-      %{on_dump: %{^data => _}} -> {:ok, data}
-      %{on_cast: %{^data => as_atom}} -> {:ok, as_atom}
-      _ -> :error
+      %{on_load: %{^data => as_atom}} ->
+        {:ok, as_atom}
+
+      %{on_dump: %{^data => _}} ->
+        {:ok, data}
+
+      %{on_cast: %{^data => as_atom}} ->
+        {:ok, as_atom}
+
+      params ->
+        {:error, validation: :inclusion, enum: Map.keys(params.on_cast)}
     end
   end
 
diff --git a/lib/ecto/schema.ex b/lib/ecto/schema.ex
index a8572daa..22d7a9ea 100644
--- a/lib/ecto/schema.ex
+++ b/lib/ecto/schema.ex
@@ -1033,11 +1033,11 @@ defmodule Ecto.Schema do
   Note `:through` associations are read-only. For example, you cannot use
   `Ecto.Changeset.cast_assoc/3` to modify through associations.
   """
-  defmacro has_many(name, queryable, opts \\ []) do
-    queryable = expand_literals(queryable, __CALLER__)
+  defmacro has_many(name, schema, opts \\ []) do
+    schema = expand_literals(schema, __CALLER__)
 
     quote do
-      Ecto.Schema.__has_many__(__MODULE__, unquote(name), unquote(queryable), unquote(opts))
+      Ecto.Schema.__has_many__(__MODULE__, unquote(name), unquote(schema), unquote(opts))
     end
   end
 
@@ -1109,11 +1109,11 @@ defmodule Ecto.Schema do
       [post] = Repo.all(from(p in Post, where: p.id == 42, preload: :permalink))
       post.permalink #=> %Permalink{...}
   """
-  defmacro has_one(name, queryable, opts \\ []) do
-    queryable = expand_literals(queryable, __CALLER__)
+  defmacro has_one(name, schema, opts \\ []) do
+    schema = expand_literals(schema, __CALLER__)
 
     quote do
-      Ecto.Schema.__has_one__(__MODULE__, unquote(name), unquote(queryable), unquote(opts))
+      Ecto.Schema.__has_one__(__MODULE__, unquote(name), unquote(schema), unquote(opts))
     end
   end
 
@@ -1316,11 +1316,11 @@ defmodule Ecto.Schema do
 
   See `many_to_many/3` for more information on this particular approach.
   """
-  defmacro belongs_to(name, queryable, opts \\ []) do
-    queryable = expand_literals(queryable, __CALLER__)
+  defmacro belongs_to(name, schema, opts \\ []) do
+    schema = expand_literals(schema, __CALLER__)
 
     quote do
-      Ecto.Schema.__belongs_to__(__MODULE__, unquote(name), unquote(queryable), unquote(opts))
+      Ecto.Schema.__belongs_to__(__MODULE__, unquote(name), unquote(schema), unquote(opts))
     end
   end
 
@@ -1612,12 +1612,12 @@ defmodule Ecto.Schema do
         {:error, changeset} -> # Handle the error
       end
   """
-  defmacro many_to_many(name, queryable, opts \\ []) do
-    queryable = expand_literals(queryable, __CALLER__)
+  defmacro many_to_many(name, schema, opts \\ []) do
+    schema = expand_literals(schema, __CALLER__)
     opts = expand_literals(opts, __CALLER__)
 
     quote do
-      Ecto.Schema.__many_to_many__(__MODULE__, unquote(name), unquote(queryable), unquote(opts))
+      Ecto.Schema.__many_to_many__(__MODULE__, unquote(name), unquote(schema), unquote(opts))
     end
   end
 


Test patch:
diff --git a/test/ecto/enum_test.exs b/test/ecto/enum_test.exs
index 77e87289..447046c7 100644
--- a/test/ecto/enum_test.exs
+++ b/test/ecto/enum_test.exs
@@ -294,62 +294,83 @@ defmodule Ecto.EnumTest do
     test "rejects bad strings" do
       type = EnumSchema.__schema__(:type, :my_enum)
 
+      {:parameterized, {Ecto.Enum, %{on_cast: on_cast}}} = type
+      enum = Map.keys(on_cast)
+
       assert %Changeset{
                valid?: false,
                changes: %{},
-               errors: [my_enum: {"is invalid", [type: ^type, validation: :cast]}]
+               errors: [my_enum: {"is invalid", [type: ^type, validation: :inclusion, enum: ^enum]}]
              } = Changeset.cast(%EnumSchema{}, %{my_enum: "bar2"}, [:my_enum])
 
       type = EnumSchema.__schema__(:type, :my_enums)
 
+      {:array, {:parameterized, {Ecto.Enum, %{on_cast: on_cast}}}} = type
+      enum = Map.keys(on_cast)
+
       assert %Changeset{
                valid?: false,
                changes: %{},
-               errors: [my_enums: {"is invalid", [type: ^type, validation: :cast]}]
+               errors: [my_enums: {"is invalid", [type: ^type, validation: :inclusion, enum: ^enum, source: [0]]}]
              } = Changeset.cast(%EnumSchema{}, %{my_enums: ["bar2"]}, [:my_enums])
 
       type = EnumSchema.__schema__(:type, :my_string_enum)
 
+      {:parameterized, {Ecto.Enum, %{on_cast: on_cast}}} = type
+      enum = Map.keys(on_cast)
+
       assert %Changeset{
                valid?: false,
                changes: %{},
-               errors: [my_string_enum: {"is invalid", [type: ^type, validation: :cast]}]
+               errors: [my_string_enum: {"is invalid", [type: ^type, validation: :inclusion, enum: ^enum]}]
              } = Changeset.cast(%EnumSchema{}, %{my_string_enum: "baar2"}, [:my_string_enum])
 
       type = EnumSchema.__schema__(:type, :my_string_enums)
 
+      {:array, {:parameterized, {Ecto.Enum, %{on_cast: on_cast}}}} = type
+      enum = Map.keys(on_cast)
+
       assert %Changeset{
                valid?: false,
                changes: %{},
-               errors: [my_string_enums: {"is invalid", [type: ^type, validation: :cast]}]
+               errors: [my_string_enums: {"is invalid", [type: ^type, validation: :inclusion, enum: ^enum, source: [0]]}]
              } = Changeset.cast(%EnumSchema{}, %{my_string_enums: ["baar2"]}, [:my_string_enums])
     end
 
     test "rejects bad integers" do
       type = EnumSchema.__schema__(:type, :my_integer_enum)
 
+      {:parameterized, {Ecto.Enum, %{on_cast: on_cast}}} = type
+      enum = Map.keys(on_cast)
+
       assert %Changeset{
                valid?: false,
                changes: %{},
-               errors: [my_integer_enum: {"is invalid", [type: ^type, validation: :cast]}]
+               errors: [my_integer_enum: {"is invalid", [type: ^type, validation: :inclusion, enum: ^enum]}]
              } = Changeset.cast(%EnumSchema{}, %{my_integer_enum: 7}, [:my_integer_enum])
 
       type = EnumSchema.__schema__(:type, :my_integer_enums)
 
+      {:array, {:parameterized, {Ecto.Enum, %{on_cast: on_cast}}}} = type
+      enum = Map.keys(on_cast)
+
       assert %Changeset{
                valid?: false,
                changes: %{},
-               errors: [my_integer_enums: {"is invalid", [type: ^type, validation: :cast]}]
+               errors: [my_integer_enums: {"is invalid", [type: ^type, validation: :inclusion, enum: ^enum, source: [0]]}]
              } = Changeset.cast(%EnumSchema{}, %{my_integer_enums: [7]}, [:my_integer_enums])
     end
 
     test "rejects bad atoms" do
       type = EnumSchema.__schema__(:type, :my_enum)
 
+      {:parameterized, {Ecto.Enum, %{on_cast: on_cast}}} = type
+      enum = Map.keys(on_cast)
+
       assert %Changeset{
                valid?: false,
                changes: %{},
-               errors: [my_enum: {"is invalid", [type: ^type, validation: :cast]}]
+               errors: [my_enum: {"is invalid", [type: ^type, validation: :inclusion, enum: ^enum]}]
              } = Changeset.cast(%EnumSchema{}, %{my_enum: :bar2}, [:my_enum])
 
       type = EnumSchema.__schema__(:type, :my_enums)