Repository: elixir-ecto/ecto
Pull request #4147 (2023-04-07 20:46:23)

Allow changesets or schemas in Ecto.Enum values / mappings functions
The current implementation of `Ecto.Enum.values/2`, `Ecto.Enum.dump_values/2`, and `Ecto.Enum.mappings/2` will only work for a module that uses `Ecto.Schema`.  However, Ecto supports schemaless changesets and so `Ecto.Enum` should too.

This PR is an idea that would allow `Ecto.Enum` to be used with a schemaless changeset by defining a type map as follows:

```elixir
types = %{
  permissions: {:array, Ecto.ParameterizedType.init(Ecto.Enum, values: [:read, :write])}
}
Ecto.Enum.values(types)
```

greg-rychlewski: Thanks for the PR. 

I'm not sure the changeset is the schemaless equivalent in this case. I believe it should be the 2-tuple of the struct and the type information. Maybe we should accept that instead?

Some other comments:

- It looks like quite a few CI tests failed. Could you please have a look?
- Could you please add tests for the new functionality?
wojtekmach: Thank you for the PR!

> ```elixir
> {:array, {:parameterized, Ecto.Enum, Ecto.Enum.init(values: [:read, :write])}}
> ```

Personally I'd like to see something more ergonomic:

```elixir
{:array, {Ecto.Enum, values: [:read, :write]}}
```

As an aside, I think I proposed it to be "the" syntax for defining enums on schemas too:

```elixir
field :mode, {:array, {Ecto.Enum, values: [:read, :write]}}
```

so we'd have nice symmetry there. But I can't find the discussion if there was one.
josevalim: This is what to use for init: https://github.com/elixir-ecto/ecto/blob/v3.9.5/lib/ecto/parameterized_type.ex#L179

For schemaless change sets, we should pass the types as argument.
noahduncan: Thanks for the quick responses, I'll work on implementing those next week.

> It looks like quite a few CI tests failed. Could you please have a look?

I'll see what I can figure out.

> Could you please add tests for the new functionality?

For sure.

Patch:
diff --git a/lib/ecto/enum.ex b/lib/ecto/enum.ex
index 4f100e26..57a2399c 100644
--- a/lib/ecto/enum.ex
+++ b/lib/ecto/enum.ex
@@ -213,35 +213,41 @@ defmodule Ecto.Enum do
     "#Ecto.Enum<values: #{inspect Keyword.keys(mappings)}>"
   end
 
-  @doc "Returns the possible values for a given schema and field"
-  @spec values(module, atom) :: [atom()]
-  def values(schema, field) do
-    schema
+  @doc "Returns the possible values for a given schema or types map and field"
+  @spec values(map | module, atom) :: [atom()]
+  def values(schema_or_types, field) do
+    schema_or_types
     |> mappings(field)
     |> Keyword.keys()
   end
 
-  @doc "Returns the possible dump values for a given schema and field"
-  @spec dump_values(module, atom) :: [String.t()] | [integer()]
-  def dump_values(schema, field) do
-    schema
+  @doc "Returns the possible dump values for a given schema or types map and field"
+  @spec dump_values(map | module, atom) :: [String.t()] | [integer()]
+  def dump_values(schema_or_types, field) do
+    schema_or_types
     |> mappings(field)
     |> Keyword.values()
   end
 
-  @doc "Returns the mappings for a given schema and field"
+  @spec mappings(map, atom) :: Keyword.t()
+  def mappings(types, field) when is_map(types) do
+    case types do
+      %{^field => {:parameterized, Ecto.Enum, %{mappings: mappings}}} -> mappings
+      %{^field => {_, {:parameterized, Ecto.Enum, %{mappings: mappings}}}} -> mappings
+      %{^field => _} -> raise ArgumentError, "#{field} is not an Ecto.Enum field"
+      %{} -> raise ArgumentError, "#{field} does not exist"
+    end
+  end
+
   @spec mappings(module, atom) :: Keyword.t()
   def mappings(schema, field) do
     try do
       schema.__changeset__()
     rescue
       _ in UndefinedFunctionError ->
-        raise ArgumentError, "#{inspect(schema)} is not an Ecto schema"
+        raise ArgumentError, "#{inspect(schema)} is not an Ecto schema or types map"
     else
-      %{^field => {:parameterized, Ecto.Enum, %{mappings: mappings}}} -> mappings
-      %{^field => {_, {:parameterized, Ecto.Enum, %{mappings: mappings}}}} -> mappings
-      %{^field => _} -> raise ArgumentError, "#{field} is not an Ecto.Enum field"
-      %{} -> raise ArgumentError, "#{field} does not exist"
+      %{} = types -> mappings(types, field)
     end
   end
 end


Test patch:
diff --git a/test/ecto/enum_test.exs b/test/ecto/enum_test.exs
index fbc6669a..03fa9634 100644
--- a/test/ecto/enum_test.exs
+++ b/test/ecto/enum_test.exs
@@ -23,6 +23,15 @@ defmodule Ecto.EnumTest do
     end
   end
 
+  @schemaless_types %{
+    my_enum: Ecto.ParameterizedType.init(Ecto.Enum, values: [:foo, :bar, :baz]),
+    my_enums: {:array, Ecto.ParameterizedType.init(Ecto.Enum, values: [:foo, :bar, :baz])},
+    my_integer_enum: Ecto.ParameterizedType.init(Ecto.Enum, values: [foo: 1, bar: 2, baz: 5]),
+    my_integer_enums: {:array, Ecto.ParameterizedType.init(Ecto.Enum, values: [foo: 1, bar: 2, baz: 5])},
+    my_string_enum: Ecto.ParameterizedType.init(Ecto.Enum, values: [foo: "fooo", bar: "baar", baz: "baaz"]),
+    my_string_enums: {:array, Ecto.ParameterizedType.init(Ecto.Enum, values: [foo: "fooo", bar: "baar", baz: "baaz"])}
+  }
+
   describe "Ecto.Enum" do
     test "schema" do
       assert EnumSchema.__schema__(:type, :my_enum) ==
@@ -468,6 +477,13 @@ defmodule Ecto.EnumTest do
       assert Ecto.Enum.values(EnumSchema, :my_integer_enum) == [:foo, :bar, :baz]
       assert Ecto.Enum.values(EnumSchema, :my_integer_enums) == [:foo, :bar, :baz]
       assert Ecto.Enum.values(EnumSchema, :virtual_enum) == [:foo, :bar, :baz]
+
+      assert Ecto.Enum.values(@schemaless_types, :my_enum) == [:foo, :bar, :baz]
+      assert Ecto.Enum.values(@schemaless_types, :my_enums) == [:foo, :bar, :baz]
+      assert Ecto.Enum.values(@schemaless_types, :my_integer_enum) == [:foo, :bar, :baz]
+      assert Ecto.Enum.values(@schemaless_types, :my_integer_enums) == [:foo, :bar, :baz]
+      assert Ecto.Enum.values(@schemaless_types, :my_string_enum) == [:foo, :bar, :baz]
+      assert Ecto.Enum.values(@schemaless_types, :my_string_enums) == [:foo, :bar, :baz]
     end
   end
 
@@ -480,6 +496,13 @@ defmodule Ecto.EnumTest do
       assert Ecto.Enum.dump_values(EnumSchema, :my_integer_enum) == [1, 2, 5]
       assert Ecto.Enum.dump_values(EnumSchema, :my_integer_enums) == [1, 2, 5]
       assert Ecto.Enum.dump_values(EnumSchema, :virtual_enum) == ["foo", "bar", "baz"]
+
+      assert Ecto.Enum.dump_values(@schemaless_types, :my_enum) == ["foo","bar", "baz"]
+      assert Ecto.Enum.dump_values(@schemaless_types, :my_enums) == ["foo","bar", "baz"]
+      assert Ecto.Enum.dump_values(@schemaless_types, :my_string_enum) == ["fooo","baar", "baaz"]
+      assert Ecto.Enum.dump_values(@schemaless_types, :my_string_enums) == ["fooo","baar", "baaz"]
+      assert Ecto.Enum.dump_values(@schemaless_types, :my_integer_enum) == [1, 2, 5]
+      assert Ecto.Enum.dump_values(@schemaless_types, :my_integer_enums) == [1, 2, 5]
     end
   end
 
@@ -492,10 +515,17 @@ defmodule Ecto.EnumTest do
       assert Ecto.Enum.mappings(EnumSchema, :my_integer_enum) == [foo: 1, bar: 2, baz: 5]
       assert Ecto.Enum.mappings(EnumSchema, :my_integer_enums) == [foo: 1, bar: 2, baz: 5]
       assert Ecto.Enum.mappings(EnumSchema, :virtual_enum) == [foo: "foo", bar: "bar", baz: "baz"]
+
+      assert Ecto.Enum.mappings(@schemaless_types, :my_enum) == [foo: "foo", bar: "bar", baz: "baz"]
+      assert Ecto.Enum.mappings(@schemaless_types, :my_enums) == [foo: "foo", bar: "bar", baz: "baz"]
+      assert Ecto.Enum.mappings(@schemaless_types, :my_string_enum) == [foo: "fooo", bar: "baar", baz: "baaz"]
+      assert Ecto.Enum.mappings(@schemaless_types, :my_string_enums) == [foo: "fooo", bar: "baar", baz: "baaz"]
+      assert Ecto.Enum.mappings(@schemaless_types, :my_integer_enum) == [foo: 1, bar: 2, baz: 5]
+      assert Ecto.Enum.mappings(@schemaless_types, :my_integer_enums) == [foo: 1, bar: 2, baz: 5]
     end
 
     test "raises on bad schema" do
-      assert_raise ArgumentError, "NotASchema is not an Ecto schema", fn ->
+      assert_raise ArgumentError, "NotASchema is not an Ecto schema or types map", fn ->
         Ecto.Enum.mappings(NotASchema, :foo)
       end
     end