defmodule Ecto.Enum do
@moduledoc """
A custom type that maps atoms to strings or integers.
`Ecto.Enum` must be used whenever you want to keep atom values in a field.
Since atoms cannot be persisted to the database, `Ecto.Enum` converts them
to a string or an integer when writing to the database and converts them back
to atoms when loading data. It can be used in your schemas as follows:
# Stored as strings
field :status, Ecto.Enum, values: [:foo, :bar, :baz]
or
# Stored as integers
field :status, Ecto.Enum, values: [foo: 1, bar: 2, baz: 5]
Therefore, the type to be used in your migrations for enum fields depend
on the choice above. For the cases above, one would do, respectively:
add :status, :string
or
add :status, :integer
Some databases also support enum types, which you could use in combination
with the above.
Composite types, such as `:array`, are also supported which allow selecting
multiple values per record:
field :roles, {:array, Ecto.Enum}, values: [:author, :editor, :admin]
Overall, `:values` must be a list of atoms or a keyword list. Values will be
cast to atoms safely and only if the atom exists in the list (otherwise an
error will be raised). Attempting to load any string/integer not represented
by an atom in the list will be invalid.
The helper function `mappings/2` returns the mappings for a given schema and
field, which can be used in places like form drop-downs. For example, given
the following schema:
defmodule EnumSchema do
use Ecto.Schema
schema "my_schema" do
field :my_enum, Ecto.Enum, values: [:foo, :bar, :baz]
end
end
You can call `mappings/2` like this:
Ecto.Enum.mappings(EnumSchema, :my_enum)
#=> [foo: "foo", bar: "bar", baz: "baz"]
If you want the values only, you can use `Ecto.Enum.values/2`, and if you want
the dump values only, you can use `Ecto.Enum.dump_values/2`.
## Embeds
`Ecto.Enum` allows to customize how fields are dumped within embeds through the
`:embed_as` option. Two alternatives are supported: `:values`, which will save
the enum keys (and not their respective mapping), and `:dumped`, which will save
the dumped value. The default is `:values`. For example, assuming the following
schema:
defmodule EnumSchema do
use Ecto.Schema
schema "my_schema" do
embeds_one :embed, Embed do
field :embed_as_values, Ecto.Enum, values: [foo: 1, bar: 2], embed_as: :values
field :embed_as_dump, Ecto.Enum, values: [foo: 1, bar: 2], embed_as: :dumped
end
end
end
The `:embed_as_values` field value will save `:foo` or `:bar`, while the
`:embed_as_dump` field value will save `1` or `2`.
"""
use Ecto.ParameterizedType
@impl true
def type(params), do: params.type
@impl true
def init(opts) do
values = opts[:values]
{type, mappings} =
cond do
is_list(values) and Enum.all?(values, &is_atom/1) ->
validate_unique!(values)
{:string, Enum.map(values, fn atom -> {atom, to_string(atom)} end)}
type = Keyword.keyword?(values) and infer_type(Keyword.values(values)) ->
validate_unique!(Keyword.keys(values))
validate_unique!(Keyword.values(values))
{type, values}
true ->
raise ArgumentError, """
Ecto.Enum types must have a values option specified as a list of atoms or a
keyword list with a mapping from atoms to either integer or string values.
For example:
field :my_field, Ecto.Enum, values: [:foo, :bar]
or
field :my_field, Ecto.Enum, values: [foo: 1, bar: 2, baz: 5]
"""
end
on_load = Map.new(mappings, fn {key, val} -> {val, key} end)
on_dump = Map.new(mappings)
on_cast = Map.new(mappings, fn {key, _} -> {Atom.to_string(key), key} end)
embed_as =
case Keyword.get(opts, :embed_as, :values) do
:values ->
:self
:dumped ->
:dump
other ->
raise ArgumentError, """
the `:embed_as` option for `Ecto.Enum` accepts either `:values` or `:dumped`,
received: `#{inspect(other)}`
"""
end
%{
on_load: on_load,
on_dump: on_dump,
on_cast: on_cast,
mappings: mappings,
embed_as: embed_as,
type: type
}
end
defp validate_unique!(values) do
if length(Enum.uniq(values)) != length(values) do
raise ArgumentError, """
Ecto.Enum type values must be unique.
For example:
field :my_field, Ecto.Enum, values: [:foo, :bar, :foo]
is invalid, while
field :my_field, Ecto.Enum, values: [:foo, :bar, :baz]
is valid
"""
end
end
defp infer_type(values) do
cond do
Enum.all?(values, &is_integer/1) -> :integer
Enum.all?(values, &is_binary/1) -> :string
true -> nil
end
end
@impl true
def cast(nil, _params), do: {:ok, nil}
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
end
end
@impl true
def load(nil, _, _), do: {:ok, nil}
def load(data, _loader, %{on_load: on_load}) do
case on_load do
%{^data => as_atom} -> {:ok, as_atom}
_ -> :error
end
end
@impl true
def dump(nil, _, _), do: {:ok, nil}
def dump(data, _dumper, %{on_dump: on_dump}) do
case on_dump do
%{^data => as_string} -> {:ok, as_string}
_ -> :error
end
end
@impl true
def equal?(a, b, _params), do: a == b
@impl true
def embed_as(_, %{embed_as: embed_as}), do: embed_as
@impl true
def format(%{mappings: mappings}) do
"#Ecto.Enum<values: #{inspect(Keyword.keys(mappings))}>"
end
@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 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 between values and dumped values"
@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 or types map"
else
%{} = types -> mappings(types, field)
end
end
end