lib/ash/type/module.ex

defmodule Ash.Type.Module do
  @constraints [
    behaviour: [
      type: :atom,
      doc: "Allows constraining the module a one which implements a behaviour"
    ],
    protocol: [
      type: :atom,
      doc: "Allows constraining the module a one which implements a protocol"
    ]
  ]

  @moduledoc """
  Stores a module as a string in the database.

  A builtin type that can be referenced via `:module`.

  ### Constraints

  #{Spark.OptionsHelpers.docs(@constraints)}
  """
  use Ash.Type

  @impl true
  def storage_type, do: :string

  @impl true
  def constraints, do: @constraints

  def apply_constraints(nil, _), do: :ok

  def apply_constraints(value, constraints) do
    []
    |> apply_behaviour_constraint(value, constraints[:behaviour])
    |> apply_protocol_constraint(value, constraints[:protocol])
    |> case do
      [] -> {:ok, value}
      errors -> {:error, errors}
    end
  end

  defp apply_behaviour_constraint(errors, _module, nil), do: errors

  defp apply_behaviour_constraint(errors, module, behaviour) do
    if Spark.implements_behaviour?(module, behaviour) do
      errors
    else
      Enum.concat(errors, [
        [
          message: "module %{module} does not implement the %{behaviour} behaviour",
          module: module,
          behaviour: behaviour
        ]
      ])
    end
  end

  defp apply_protocol_constraint(errors, _module, nil), do: errors

  defp apply_protocol_constraint(errors, module, protocol) do
    Protocol.assert_protocol!(protocol)
    Protocol.assert_impl!(protocol, module)

    errors
  rescue
    ArgumentError ->
      Enum.concat(errors, [
        [
          message: "module %{module} does not implement the %{protocol} protocol",
          module: module,
          protocol: protocol
        ]
      ])
  end

  @impl true
  def cast_input(value, _) when is_atom(value) do
    if Code.ensure_loaded?(value) do
      {:ok, value}
    else
      :error
    end
  end

  def cast_input("", _), do: {:ok, nil}

  def cast_input("Elixir." <> _ = value, _) do
    module = Module.concat([value])

    if Code.ensure_loaded?(module) do
      {:ok, module}
    else
      :error
    end
  end

  def cast_input(value, _) when is_binary(value) do
    atom = String.to_existing_atom(value)

    if Code.ensure_loaded?(atom) do
      {:ok, atom}
    else
      :error
    end
  rescue
    ArgumentError ->
      :error
  end

  def cast_input(_, _), do: :error

  @impl true
  def cast_stored(nil, _), do: {:ok, nil}

  def cast_stored(value, _) when is_atom(value) do
    if Code.ensure_loaded?(value) do
      {:ok, value}
    else
      :error
    end
  end

  def cast_stored("Elixir." <> _ = value, _) do
    module = Module.concat([value])

    if Code.ensure_loaded?(module) do
      {:ok, module}
    else
      :error
    end
  end

  def cast_stored(value, _) when is_binary(value) do
    atom = String.to_existing_atom(value)

    if Code.ensure_loaded?(atom) do
      {:ok, atom}
    else
      :error
    end
  rescue
    ArgumentError -> :error
  end

  def cast_stored(_, _), do: :error

  @impl true
  def dump_to_native(nil, _), do: {:ok, nil}

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

  def dump_to_native(_, _), do: :error
end