Thanks @karlosmid and @Eiji ! I had not seen either of those libraries and are exactly what I was curious about, I’ll have to dig into them… we’re not likely to replace our in-house tool because we use it extensively and it works well, but, good to be aware of/make easier for others to discover from this discussion perhaps…
For what it’s worth/in case anyone is interested, here’s the code from our in homebrewed solution, including an extension for Absinthe integration and for supporting constraints in migrations via a versioning convention… for usage, you use the module defining a new Enum, and then import or require your enum module to expose the macros for usage in your code…
defmodule Optimizer.Ecto.Types.Enumerable do
@moduledoc """
Enumerable type for Ecto fields. Main benefit vs Ecto.Enum being first class `values` and macro functions for each
value so that compilation will fail if they are removed/renamed.
NOTE: if an enum is stored in the database, DO NOT simply drop a value,
it should be deprecated first, then removed after a migration to remove the value from the database.
deprecation is a new feature used via an optional keyword argument to the `use` macro, e.g.
use Optimizer.Ecto.Types.Enumerable, values: [:foo, :bar], deprecated: [:baz]
Based on:
https://github.com/KamilLelonek/exnumerator/blob/2e72dd1a34e119e5198aebf18ebfb4bed77f3646/lib/exnumerable.ex
"""
# credo:disable-for-this-file
defmacro __using__(opts) do
quote location: :keep,
bind_quoted: [current_values: opts[:values], deprecated_values: opts[:deprecated] || []] do
@behaviour Ecto.Type
require Logger
@impl Ecto.Type
def type, do: :string
def __current_values, do: unquote(current_values)
def __values, do: unquote((current_values ++ deprecated_values) |> Enum.sort())
# these are used in guards hence macros
defmacro current_values, do: unquote(current_values)
defmacro values, do: unquote((current_values ++ deprecated_values) |> Enum.sort())
values = current_values ++ deprecated_values
for value <- values, atom = value, string = Atom.to_string(value) do
deprecated? = deprecated_values |> Enum.member?(value)
downcased_atom =
string
|> String.downcase()
|> String.to_atom()
upcased_string = String.upcase(string)
@impl Ecto.Type
def cast(unquote(string)), do: {:ok, unquote(atom)}
if upcased_string != string do
def cast(unquote(upcased_string)), do: {:ok, unquote(atom)}
end
def cast(unquote(atom)), do: {:ok, unquote(atom)}
@impl Ecto.Type
if deprecated? do
def load(unquote(string)) do
Logger.warning(
"Deprecated value #{unquote(string)} loaded for #{inspect(__MODULE__)}"
)
{:ok, unquote(atom)}
end
else
def load(unquote(string)), do: {:ok, unquote(atom)}
end
@impl Ecto.Type
if deprecated? do
def dump(unquote(string)) do
Logger.warning(
"Deprecated value #{unquote(string)} dumped for #{inspect(__MODULE__)}"
)
{:ok, unquote(string)}
end
def dump(unquote(atom)) do
Logger.warning("Deprecated value #{unquote(atom)} dumped for #{inspect(__MODULE__)}")
{:ok, unquote(atom)}
end
else
def dump(unquote(string)), do: {:ok, unquote(string)}
def dump(unquote(atom)), do: {:ok, unquote(string)}
end
if deprecated? do
defmacro unquote(downcased_atom)() do
IO.warn(
"Deprecated enum value #{unquote(downcased_atom)} used, please update code, this is only supported for legacy compatibility",
Macro.Env.stacktrace(__ENV__)
)
unquote(atom)
end
else
defmacro unquote(downcased_atom)(), do: unquote(atom)
end
end
def cast(_), do: :error
def load(_), do: :error
def dump(_), do: :error
def cast!(term) do
case cast(term) do
{:ok, value} ->
value
:error ->
raise Ecto.CastError,
message: "cannot cast #{inspect(term)} as #{inspect(__MODULE__)}"
end
end
@impl Ecto.Type
def embed_as(_), do: :self
@impl Ecto.Type
def equal?(value_1, value_2), do: value_1 === value_2
end
end
end
and our absinthe EnumBuilder
defmodule EnumBuilder do
@moduledoc """
This module is used to dynamically build an enum type for the GraphQL schema.
"""
alias Absinthe.Blueprint
alias Absinthe.Blueprint.Schema
alias Absinthe.Schema.Notation
alias Absinthe.Type.Deprecation
@spec run(Blueprint.t(), atom(), module()) :: Blueprint.t()
def run(blueprint = %Blueprint{}, name, enum_module) do
%{schema_definitions: [schema]} = blueprint
new_enum = build_dynamic_enum(enum_module, name)
new_schema = %{schema | type_definitions: [new_enum | schema.type_definitions]}
%{blueprint | schema_definitions: [new_schema]}
end
defp build_dynamic_enum(enum_module, name) do
%Schema.EnumTypeDefinition{
name: name |> Atom.to_string() |> Absinthe.Utils.camelize(),
identifier: name,
module: __MODULE__,
__reference__: Notation.build_reference(__ENV__),
values: values(enum_module)
}
end
defp values(enum_module) do
# require enum_module
{current_values, values} = extract_module_values(enum_module)
for value <- values do
deprecated? = value not in current_values
define_value(
value,
String.upcase("#{value}"),
enum_value_description(enum_module, value),
deprecated?
)
end
end
defp extract_module_values(enum_module) do
{
enum_module.__current_values(),
enum_module.__values()
}
end
defp enum_value_description(enum_module, value) do
if function_exported?(enum_module, :to_description, 1) do
enum_module.to_description(value)
else
nil
end
end
defp define_value(key, name, description, deprecated?) do
%Schema.EnumValueDefinition{
identifier: key,
value: key,
name: name,
description: description,
deprecation:
if deprecated? do
%Deprecation{reason: "deprecated"}
else
nil
end,
module: __MODULE__,
__reference__: Notation.build_reference(__ENV__)
}
end
defmacro __using__(_options) do
quote do
Module.register_attribute(__MODULE__, :dynamic_enums, accumulate: true, persist: true)
@before_compile unquote(__MODULE__)
import unquote(__MODULE__)
end
end
defmacro __before_compile__(_env) do
quote do
def __all_dynamic_enums__ do
@dynamic_enums
end
end
end
defmacro dynamic_enum(enum_identifier, enum_module) do
%{module: mod} = __CALLER__
quote bind_quoted: binding() do
Module.put_attribute(mod, :dynamic_enums, {enum_identifier, enum_module})
end
end
end
and middleware
defmodule DynamicEnumCreationPhase do
@moduledoc """
based on Absinthe phases https://hexdocs.pm/absinthe/1.5.0/Absinthe.Phase.html
"""
alias Absinthe.Blueprint
alias Absinthe.Pipeline
alias Absinthe.Phase
@behaviour Phase
@spec get_module :: __MODULE__
def get_module, do: __MODULE__
@spec pipeline(Pipeline.t()) :: Pipeline.t()
def pipeline(pipeline) do
Pipeline.insert_after(pipeline, Phase.Schema.TypeImports, __MODULE__)
end
@impl Phase
def run(blueprint = %Blueprint{}, _) do
{:ok,
Enums.__all_dynamic_enums__()
|> Enum.reduce(blueprint, fn {identifier, enum_module}, acc ->
EnumBuilder.run(acc, identifier, enum_module)
end)}
end
end
We also add in our repo.ex file, though this is purely optional/we use less now than we used to
@spec create_enum_constraint(atom | binary, any, :array | :string, maybe_improper_list) :: %{
:__struct__ => Ecto.Migration.Constraint | Ecto.Migration.Index | Ecto.Migration.Table,
:prefix => any,
optional(any) => any
}
def create_enum_constraint(table, field, field_type, enum_list) when is_list(enum_list) do
check = build_enum_constraint_check(field, enum_list, field_type)
Migration.create(Migration.constraint(table, :"valid_#{field}", check: check))
end
@spec drop_enum_constraint(atom | binary, any) :: %{:prefix => any, optional(any) => any}
def drop_enum_constraint(table, field) do
Migration.drop(Migration.constraint(table, :"valid_#{field}"))
end
defp build_enum_constraint_check(field, enum_list, :string) do
"#{field} ~ '^(#{Enum.join(enum_list, "|")})$'"
end
defp build_enum_constraint_check(field, enum_list, :array) do
array = form_array_string(enum_list, [])
~s(#{field} <@ ARRAY[#{array}])
end
defp form_array_string([head | []], acc) do
[wrap_in_io_list(head, false) | acc]
|> Enum.reverse()
|> to_string()
end
defp form_array_string([head | tail], acc) do
form_array_string(tail, [wrap_in_io_list(head) | acc])
end
defp wrap_in_io_list(val, trailing_comma? \\ true) do
initial =
if trailing_comma? do
[","]
else
[]
end
[[~S('), to_string(val), ~S(')] | initial]
end
Could publish as a library, but, not a ton of code and not sure how it compares yet to above existing libraries I wasn’t aware of so just throwing out there for feedback/interest