Packages

Adds support for enumerated types to Ecto. Unlike ecto_enum, these enums assume the database will take and return the enum's values by their string representations.

Current section

Files

Jump to
ecto_homoiconic_enum lib ecto_homoiconic_enum.ex
Raw

lib/ecto_homoiconic_enum.ex

defmodule EctoHomoiconicEnum do
@moduledoc """
Support for defining enumerated types.
See `EctoHomoiconicEnum.defenum/2` for usage.
"""
defmodule ConflictingTypesError do
defexception [:message]
def exception({module, mappings}) do
if expected = prominent(histogram(mappings)) do
conflicts =
case expected do
:integers ->
Enum.reduce(mappings, [], fn ({member, internal}, conflicts) ->
if is_binary(internal) do [member | conflicts] else conflicts end
end)
:binaries ->
Enum.reduce(mappings, [], fn ({member, internal}, conflicts) ->
if is_integer(internal) do [member | conflicts] else conflicts end
end)
end
culprits =
conflicts
|> Enum.map(&("`#{&1}`"))
|> Enum.join(", ")
plural =
length(conflicts) >= 2
indicative =
if plural do "are" else "is" end
message =
"You have specified conflicting data types for `#{module}`! " <>
"You can only map to one data type, i.e. integers or strings, but not both. " <>
"Specifically, #{culprits} #{indicative} not mapped to #{expected} while other members are."
%__MODULE__{message: message}
else
message =
"You have specified conflicting data types for `#{module}`! " <>
"You can only map to one data type, i.e. integers or strings, but not both."
%__MODULE__{message: message}
end
end
defp histogram(mappings) when is_list(mappings) do
Enum.reduce(mappings, %{integers: 0, binaries: 0}, fn (mapping, histogram) ->
case mapping do
{_, internal} when is_integer(internal) -> %{histogram | integers: histogram[:integers] + 1}
{_, internal} when is_binary(internal) -> %{histogram | binaries: histogram[:binaries] + 1}
end
end)
end
defp prominent(%{integers: integers, binaries: binaries}) when integers > binaries, do: :integers
defp prominent(%{integers: integers, binaries: binaries}) when binaries > integers, do: :binaries
defp prominent(_), do: nil
end
@doc """
Defines a custom enumerated `Ecto.Type`.
It can be used like any other `Ecto.Type`:
import EctoHomoiconicEnum, only: [defenum: 2]
defenum User.Status, active: 1, inactive: 2, archived: 3
defmodule User do
use Ecto.Model
schema "users" do
field :status, User.Status
end
end
In this example, the `status` column can only assume the three stated values
(or `nil`), and will automatically convert atoms and strings passed to it
into the specified stored value. Integers in this case. This applies to
saving the model, invoking `Ecto.Changeset.cast/4`, or performing a query on
the `status` field.
Continuing from the previous example:
iex> user = Repo.insert!(%User{status: :active})
iex> Repo.get(User, user.id).status
:registered
iex> %{changes: changes} = cast(%User{}, %{"status" => "inactive"}, [:status], [])
iex> changes.status
:inactive
iex> from(u in User, where: u.status == :inactive) |> Repo.all |> length
1
Passing an invalid value to a `Ecto.Changeset.cast` will add an error to
`changeset.errors` field.
iex> changeset = cast(%User{}, %{"status" => "minister_of_silly_walks"}, [:status], [])
iex> changeset.errors
[status: "is invalid"]
Likewise, putting an invalid value directly into a model struct will casue an
error when calling `Repo` functions.
The generated module `User.Status` also exposes a reflection functions for
inspecting the type at runtime.
iex> User.Status.__members__()
[:active, :inactive, :archived]
iex> User.Status.__mappings__()
[active: 1, inactive: 2, archived: 3]
"""
defmacro defenum(module, list_or_mapping) when is_list(list_or_mapping) do
quote do
list_or_mapping = Macro.escape(unquote(list_or_mapping))
storage = EctoHomoiconicEnum.storage(list_or_mapping)
if storage in [:indeterminate],
do: raise EctoHomoiconicEnum.ConflictingTypesError, {unquote(module), list_or_mapping}
{member_to_internal, internal_to_member} = EctoHomoiconicEnum.mapping(list_or_mapping)
members = Map.keys(member_to_internal)
internals = Map.values(member_to_internal)
defmodule unquote(module) do
@behaviour Ecto.Type
@storage storage
@members members
@internals internals
@member_to_internal member_to_internal
@internal_to_member internal_to_member
def type, do: @storage
def cast(stored) when is_integer(stored),
do: Map.fetch(@internal_to_member, stored)
def cast(member) when is_binary(member),
do: cast(String.to_existing_atom(member))
def cast(member) when member in @members,
do: {:ok, member}
def cast(_), do: :error
def dump(stored) when is_binary(stored),
do: Map.fetch(@member_to_internal, String.to_existing_atom(stored))
def dump(stored) when is_atom(stored),
do: Map.fetch(@member_to_internal, stored)
def dump(stored) when stored in @internals,
do: {:ok, stored}
def dump(_), do: :error
def load(internal), do: Map.fetch(@internal_to_member, internal)
def __members__(), do: @members
def __mappings__(), do: @member_to_internal
end
end
end
# Tries to determine the appropriate backing type ("storage") based on the
# provided mappings. Defaults to `string` when not provided any explicit
# mapping.
def storage(list_or_mapping) when is_list(list_or_mapping) do
cond do
Enum.all?(list_or_mapping, &(is_atom(&1) or is_binary(&1))) -> :string
Enum.all?(list_or_mapping, &(is_integer(elem(&1, 1)))) -> :integer
Enum.all?(list_or_mapping, &(is_binary(elem(&1, 1)))) -> :string
true -> :indeterminate
end
end
# Builds look up tables that map members to their stored value counterparts
# and vice versa.
def mapping(list_or_mapping) when is_list(list_or_mapping) do
{members, internal} = cond do
Enum.all?(list_or_mapping, &is_atom/1) ->
{list_or_mapping, Enum.map(list_or_mapping, &Atom.to_string/1)}
Enum.all?(list_or_mapping, &is_binary/1) ->
{Enum.map(list_or_mapping, &Atom.to_string/1), list_or_mapping}
true ->
{Keyword.keys(list_or_mapping), Keyword.values(list_or_mapping)}
end
{Enum.zip(members, internal) |> Map.new,
Enum.zip(internal, members) |> Map.new}
end
end