lib/ash/type/enum.ex

defmodule Ash.Type.Enum do
  @moduledoc """
  A type for abstracting enums into a single type.

  For example, your existing attribute might look like:
  ```elixir
  attribute :status, :atom, constraints: [one_of: [:open, :closed]]
  ```

  But as that starts to spread around your system, you may find that you want
  to centralize that logic. To do that, use this module to define an Ash type
  easily:

  ```elixir
  defmodule MyApp.TicketStatus do
    use Ash.Type.Enum, values: [:open, :closed]
  end
  ```

  Then, you can rewrite your original attribute as follows:

  ```elixir
  attribute :status, MyApp.TicketStatus
  ```

  Valid values are:

  * The atom itself, e.g `:open`
  * A string that matches the atom, e.g `"open"`
  * A string that matches the atom after being downcased, e.g `"OPEN"` or `"oPeN"`
  * A string that matches the stringified, downcased atom, after itself being downcased.
    This allows for enum values like `:Open`, `:SomeState` and `:Some_State`

  ## Custom input values

  If you need to accept inputs beyond those described above while still mapping them to one
  of the enum values, you can override the `match/1` callback.

  For example, if you want to map both the `:half_empty` and `:half_full` states to the same enum
  value, you could implement it as follows:

  ```elixir
  defmodule MyApp.GlassState do
    use Ash.Type.Enum, values: [:empty, :half_full, :full]

    def match(:half_empty), do: {:ok, :half_full}
    def match("half_empty"), do: {:ok, :half_full}
    def match(value), do: super(value)
  end
  ```

  In the provided example, if no additional value is matched, `super(value)` is called, invoking
  the default implementation of `match/1`. This approach is typically suitable if you only aim to
  extend default matching rather than completely reimplementing it.

  ### Caveats

  Additional input values are not exposed in derived interfaces. For example, `HALF_EMPTY` will not
  be present as a possible enum value when using `ash_graphql`.

  Moreover, only explicitly matched values are mapped to the enum value. For instance,
  `"HaLf_emPty"` would not be accepted by the code provided earlier. If case normalization is
  needed for additional values, it must be explicitly implemented.

  ## Value descriptions
  It's possible to associate a description with a value by passing a `{value, description}` tuple
  inside the values list, which becomes a keyword list:

  ```elixir
  defmodule MyApp.TicketStatus do
    use Ash.Type.Enum,
      values: [
        open: "An open ticket",
        closed: "A closed ticket"
      ]
  end
  ```

  This can be used by extensions to provide detailed descriptions of enum values.

  The description of a value can be retrieved with `description/1`:

  ```elixir
  MyApp.TicketStatus.description(:open)
  iex> "An open ticket"
  ```
  """
  @doc "The list of valid values (not all input types that match them)"
  @callback values() :: [atom]
  @doc "The description of the value, if existing"
  @callback description(atom) :: String.t() | nil
  @doc "true if a given term matches a value"
  @callback match?(term) :: boolean
  @doc "finds the valid value that matches a given input term"
  @callback match(term) :: {:ok, atom} | :error

  defmacro __using__(opts) do
    quote location: :keep, generated: true do
      use Ash.Type

      require Ash.Expr

      @behaviour unquote(__MODULE__)

      @values unquote(__MODULE__).build_values(unquote(opts[:values]))

      @description_map unquote(__MODULE__).build_description_map(unquote(opts[:values]))

      @string_values @values |> Enum.map(&to_string/1)

      @any_not_downcase? Enum.any?(@string_values, fn value -> String.downcase(value) != value end)

      @impl unquote(__MODULE__)
      def values, do: @values

      @impl unquote(__MODULE__)
      def description(value) when value in @values, do: Map.get(@description_map, value)

      @impl Ash.Type
      def storage_type, do: :string

      @impl Ash.Type
      def generator(_constraints) do
        StreamData.member_of(@values)
      end

      @impl Ash.Type
      def cast_input(nil, _) do
        {:ok, nil}
      end

      def cast_input(value, _) do
        match(value)
      end

      @impl Ash.Type
      def cast_stored(nil, _), do: {:ok, nil}

      def cast_stored(value, _) do
        match(value)
      end

      @impl Ash.Type
      def dump_to_native(nil, _) do
        {:ok, nil}
      end

      def dump_to_native(value, _) do
        {:ok, to_string(value)}
      end

      @impl true
      def cast_atomic(new_value, _constraints) do
        if @any_not_downcase? do
          {:atomic,
           Ash.Expr.expr(
             if ^new_value in ^@values do
               string_downcase(^new_value)
             else
               error(
                 Ash.Error.Changes.InvalidChanges,
                 message: "must be one of %{values}",
                 vars: %{values: ^Enum.join(@values, ", ")}
               )
             end
           )}
        else
          error_expr =
            Ash.Expr.expr(
              error(
                Ash.Error.Changes.InvalidChanges,
                message: "must be one of %{values}",
                vars: %{values: ^Enum.join(@values, ", ")}
              )
            )

          Enum.reduce(@values, {:atomic, error_expr}, fn valid_value, {:atomic, expr} ->
            expr =
              Ash.Expr.expr(
                if string_downcase(^new_value) == string_downcase(^valid_value) do
                  ^valid_value
                else
                  ^expr
                end
              )

            {:atomic, expr}
          end)
        end
      end

      @impl unquote(__MODULE__)
      @spec match?(term) :: boolean
      def match?(term) do
        case match(term) do
          {:ok, _} -> true
          _ -> false
        end
      end

      @impl unquote(__MODULE__)
      @spec match(term) :: {:ok, atom} | :error
      def match(value) when value in @values, do: {:ok, value}
      def match(value) when value in @string_values, do: {:ok, String.to_existing_atom(value)}

      def match(value) do
        value =
          value
          |> to_string()
          |> String.downcase()

        match =
          Enum.find_value(@values, fn valid_value ->
            sanitized_valid_value =
              valid_value
              |> to_string()
              |> String.downcase()

            if sanitized_valid_value == value do
              valid_value
            end
          end)

        if match do
          {:ok, match}
        else
          :error
        end
      rescue
        _ ->
          :error
      end

      defoverridable match: 1, storage_type: 0
    end
  end

  @doc false
  def build_description_map(values) do
    values
    |> verify_values!()
    |> Enum.reduce(%{}, fn
      {value, description}, acc when is_binary(description) -> Map.put(acc, value, description)
      _value_with_no_description, acc -> acc
    end)
  end

  @doc false
  def build_values(values) do
    values
    |> verify_values!()
    |> Enum.map(fn
      {value, _description} -> value
      value -> value
    end)
  end

  @doc false
  def verify_values!(values) when is_list(values) do
    Enum.each(values, fn
      value when is_atom(value) or is_binary(value) ->
        :ok

      {value, nil} when is_atom(value) or is_binary(value) ->
        :ok

      {value, description} when (is_atom(value) or is_binary(value)) and is_binary(description) ->
        :ok

      other ->
        raise(
          "`values` must be a list of `atom | string` or {`atom | string`, string} tuples, got #{inspect(other)}"
        )
    end)

    values
  end

  def verify_values!(nil) do
    raise("Must provide `values` option for `use #{inspect(__MODULE__)}`")
  end

  def verify_values!(values) do
    raise("Must provide a list in `values`, got #{inspect(values)}")
  end
end