bglusman

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

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

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

axelson

Scenic Core Team

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

Where Next?

Popular in Discussions Top

vans163
So useless benchmarks aside, Its possible to write a webserver that can serve 300k requests per second (perhaps more with optimizations)....
New
Nvim
Anybody knows a comprehensive comparison of Django and Phoenix, thanks for the help. Where are they similar? Where do they differ the m...
New
Fl4m3Ph03n1x
Background This question comes mainly from my ignorance. Today is Black Friday, one of my favorite days of the year to buy books. One boo...
New
WolfDan
After doing a port from a c++ library to my project in phoenix I’ve seen that I need a faster way to run this algorithm and I found this ...
New
chuck
Let me start by stating an assumption: Phoenix is a great approach to building REST APIs. There are many reasons for this, but I will ass...
New
sergio
There’s a new TIOBE index report that came out that shows Elixir is still not in the top 50 used languages. It also goes on to call Elix...
New
acrolink
How does the two languages compare when it comes to server side application development? Any experiences or ideas? Thank you.
New
AstonJ
Seen any cool LiveView demos, sample apps or examples? Please post them here! :003:
New
Qqwy
Looking at the stacks that existing large companies have used, WhatsApp internally uses Mnesia to store the messages, while Discord uses ...
New
kostonstyle
Hi all How can I compare haskell with elixir, included tools, webservices, ect. Thanks
New

Other popular topics Top

lastday4you
I wanted to check elixir version in phoenix because i found that my elixir is 1.5 but when i use Enum.chunk_by it said the function is un...
New
mcarvalho
What is the difference between System.get_env and Application.get_env? For example, what are best practices to use one versus another.
New
chrismccord
This release brings a number of exciting features, including integration with the new Phoenix LiveDashboard and Phoenix LiveView. There h...
New
joeerl
Hello again - after a longish gap I’ve decided I really must dig into Elixir and see what’s been happening here - so I have a few questio...
New
RisingFromAshes
I’ve read in another post that it may be possible with a router helper - but I couldn’t find an appropriate one, and tbh, I’m still just ...
New
nobody
Hi! In PHP: $_SERVER[‘SERVER_ADDR’] - in Elixir? Searched the docs for ip address and the web, no good results. Thanks!
New
shijith.k
I am trying to start a new phoenix project with elixir 1.9, but mix phx.new does not work. It says that ** (Mix) The task "phx.new" could...
New
axelson
This post is a wiki (feel free to hit the edit button near the bottom right of this post to add your own changes!) This post collects co...
239 48342 226
New
openscript
Hello! Sorry for this astonishing simple question, but I’m really stuck. I try to set up the intellij-elixir plugin, but I don’t know ho...
New
vonH
In asking this question I am more interested about the expressiveness of the language itself and less concerned about the availability of...
New

Latest on Elixir Forum

We're in Beta

About us Mission Statement