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 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
Eiji

Eiji

You define enum module which itself does nothing except storing compile-time data from DSL and for that module you add one or more components that uses that data to generate a function at compile-time.

I support various scenarios:

  1. Simple - just storing an integer/string in the database - only Elixir-side validations - good for a single project

  2. Advanced - as same as simple stores a static list of values, but using a migration and adapter you can create an enumerated type in database - validations in both Elixir in database - good for working on same db in multiple projects - limited support for databases supporting enumerated types

  3. Custom - like Advanced, but with support for much more database like in Simple; it uses a relation instead of the enumerated type - validations in both Elixir in database - good for runtime-determined values (like user roles)

Both Simple and Advanced cases (Enumex.Static) have an absinthe component and various other components as well … I believe my solution is therefore the most feature-complete.

bglusman

bglusman

Oh I forgot about this library, I was talking about the built in Ecto.Enum but I had seen and probably used this library years ago becore Ecto.Enum was introduced in Ecto core… being able to check if a string IS a valid value is an interesting tool/usecase, though I’m not sure when I’d use it vs just patternmatching on the actual value or membership in the full list of values? Anyway, thanks for reminding me of!

Where Next?

Popular in Discussions Top

jdumont
I could write forever about this, but I’ll do my best to keep it succinct. For anyone familiar with event sourcing, what is your opinion...
New
tcoopman
Yesterday I watched OpenTelemetry: From Desire to Dashboard and it triggered me to take a new look at telemetry in Elixir. But I don’t k...
New
fredwu
Hey folks, Just wanted to share my journey and experiences so far. Preface: I’ve been using Elixir for about 10 years now, so relatively...
New
sym_num
I created a Forth processor in Elixir. This is my hobby project. https://github.com/sasagawa888/Forth
New
marciol
Thinking about how Contexts on Phoenix 1.3 are beautiful in the simplicity, elegance and minimalism, coalesced in the smallest unity of E...
New
jonator
FLAME is basically the dream for running AI agents that need system command access such as coding agents. However, I have concerns about ...
New
bartblast
Added by a user on X: https://x.com/aldicodes/status/1952095492139299036
New
bartblast
I recently started a discussion about reactive patterns in Hologram, but I realized I need to step back and design the composability arch...
New
ronindev
Hey everyone! :waving_hand: I just wanted to recommend https://seenode.com/ for deploying Phoenix apps. I’ve been using it recently and ...
New
sergio
Laravel just announced their Series A round for $57 million. If Laravel wasn’t already the defacto PHP stack, it now most certainly is. T...
New

Other popular topics Top

sergio
In Ruby, I can go: User.find_by(email: "foobar@email.com").update(email: "hello@email.com") How can I do something similar in Elixir? ...
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
New
danschultzer
None of the current solutions worked well for me, so I went ahead and built a user management system from scratch. This project took far...
548 27727 240
New
minhajuddin
I have seen a lot of code which picks the first element from a list using Enum.at(0) instead of List.first. Is there a reason why people ...
New
msaraiva
Surface is an experimental library built on top of Phoenix LiveView and its new LiveComponent API that aims to provide a more declarative...
564 42633 214
New
electic
Hi, I am new to Elixir. I am trying to use the DateTime component to insert a date into MySQL however the there seems to be no way to fo...
New
9mm
I am constructing a JSON object (map) and I need to conditionally set a field. I’m trying to write proper elixir-way code… and I’m at a l...
New
lucidguppy
I have a super simple question about elixir - how would I take a file like this foo bar baz and output a new file that enumerates th...
New
aesmail
Hello guys, I have finally made it. I created an admin interface for a framework. It’s been on my todo list for years and with the curre...
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

We're in Beta

About us Mission Statement