bglusman
How do you handle enums/does Ecto.Enum feel like "not enough"?
I’m curious how others in the community deal with first class enums… especially crossing boundaries like database, code logic, and absinthe? My team and I developed a home baked solution that I quite like, and am tempted to open source, though its not very much code, but I thought I’d check first what others are doiing and whether there are other solutions out there I’m not aware of…
What our solution mostly does that I like is, makes them first class, makes it easy to use them both in the database and in code, and expose as first class enums in absinthe/graphQL, supports optionally enforcing the limitation in database, supports deprecation, and supports versioning (though versioning support is pretty limited/manual and mostly tied to the DB enforcement to allow dropping the old constraint and adding the new one in a safe, DRY way…)
Are there other solutions supporting all of that, and/or more I’m not thinking of/haven’t had to deal with?
By the way happy to just share the few modules of code here as snippets instead of/in addition to “open sourcing” as a library/package, just curiious as the main point here is just curiosity/discussion on how others handle and whether its a pain point that wants tackling better than Ecto.Enum does at present… dont’ want to focus on our solution as I don’t think its anything amazing, but it works well enough for us…
Most Liked
bglusman
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
bglusman
I do recall looking at typedstruct at some point and its nice it saves some typing and all, but, I’m not sure if I realized the direction it went, allowing you to use types in your struct definition as opposed to saving you tying up a type for your struct… that’s interesting… but given the direction of Elixir’s support for static typing more directly in the language, I’m not likely to start taking up a tool now that relies on the… what I’ll call deprecated type syntax? Since my understanding is there may be support for migrating that to the new type system, but the syntax and behavior will definitely be different.
But while the compile time errors there are nice, it does nothing to address use of the values elsewhere in code, as in my experience when you have enums, you need to reference specific values in multiple places, and one of the things I find inadequate about Ecto.Enum is it doesn’t help you do that in a DRY or safe way protecting from typos etc… our module (mostly) does, as long as its used “correctly”…
axelson
GitHub - gjaldon/ecto_enum: Ecto extension to support enums in models · GitHub works pretty well for my needs. And it generates functions that allow you to use the enums elsewhere in the codebase, e.g.
iex> MyApp.SomeEnum.valid_value?("standard")
true
iex> MyApp.SomeEnum.valid_value?("bogus_value")
false
Popular in Discussions
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance









