lib/blunt/state.ex

defmodule Blunt.State do
  @moduledoc false

  alias Blunt.{Error, State}
  alias Blunt.Message.{Changeset, Schema, Schema.Fields}

  defmodule Error do
    defexception [:errors]

    def message(%{errors: errors}) do
      inspect(errors)
    end
  end

  defmacro __using__(_opts) do
    quote do
      use Blunt.Message.Compilation

      @primary_key_type false
      @require_all_fields? false
      @create_jason_encoders? false

      Module.register_attribute(__MODULE__, :schema_fields, accumulate: true)

      @before_compile Blunt.State

      import Blunt.State, only: :macros
    end
  end

  @spec field(name :: atom(), type :: atom(), keyword()) :: any()
  defmacro field(name, type, opts \\ []),
    do: Fields.record(name, type, opts)

  defmacro __before_compile__(env) do
    schema = Schema.generate(env)
    state_update = State.generate_update()
    field_access_functions = State.generate_field_access_functions(env)

    [schema, state_update] ++ field_access_functions
  end

  @doc false
  def generate_field_access_functions(%{module: module}) do
    module
    |> Module.get_attribute(:schema_fields)
    |> Enum.map(&generate_field_access/1)
  end

  @doc false
  defp generate_field_access({name, _type, _config}) do
    getter = String.to_atom("get_#{name}")
    putter = String.to_atom("put_#{name}")

    quote do
      def unquote(getter)(%__MODULE__{} = state) do
        Map.fetch!(state, unquote(name))
      end

      def unquote(putter)(%__MODULE__{} = state, value) do
        State.put(__MODULE__, state, unquote(name), value)
      end
    end
  end

  @doc false
  def generate_update do
    quote do
      def update(%__MODULE__{} = state, values) do
        State.update(__MODULE__, state, values)
      end
    end
  end

  @doc false
  def update(state_module, state, values) do
    attrs = Blunt.Message.Input.normalize(values, state_module)

    types =
      :fields
      |> state_module.__schema__()
      |> Enum.into(%{}, fn field -> {field, state_module.__schema__(:type, field)} end)

    case Ecto.Changeset.cast({state, types}, attrs, Map.keys(types)) do
      %{valid?: true} = changeset -> Ecto.Changeset.apply_changes(changeset)
      changeset -> raise Error, errors: Changeset.format_errors(changeset)
    end
  end

  @doc false
  def put(state_module, state, key, value),
    do: update(state_module, state, Map.new([{key, value}]))
end