lib/one_piece/commanded/value_object.ex

defmodule OnePiece.Commanded.ValueObject do
  @moduledoc """
  Defines "Value Object" modules.
  """

  alias Ecto.Changeset

  @doc """
  Converts the module into an `Ecto.Schema` and add factory functions to create structs.

  ## Using

  - `Ecto.Schema`
  - `Ecto.Type`

  ## Derives

  - `Jason.Encoder`

  ## Usage

      defmodule MyValueObject do
        use OnePiece.Commanded.ValueObject

        embedded_schema do
          field :title, :string
          # ...
        end
      end

      {:ok, my_value} = MyValueObject.new(%{title: "Hello, World!"})
  """
  @spec __using__(opts :: []) :: any()
  defmacro __using__(_opts \\ []) do
    quote do
      alias OnePiece.Commanded.ValueObject

      use Ecto.Schema
      use Ecto.Type

      @derive Jason.Encoder
      @primary_key false

      @before_compile OnePiece.Commanded.ValueObject

      @doc """
      Creates a `t:t/0`.
      """
      @spec new(attrs :: map()) :: {:ok, %__MODULE__{}} | {:error, Ecto.Changeset.t()}
      def new(attrs) do
        ValueObject.__new__(__MODULE__, attrs)
      end

      @doc """
      Creates a `t:t/0`.
      """
      @spec new!(attrs :: map()) :: %__MODULE__{}
      def new!(attrs) do
        ValueObject.__new__!(__MODULE__, attrs)
      end

      @doc """
      Returns an `t:Ecto.Changeset.t/0` for a given `t:t/0` value object.
      """
      @spec changeset(message :: %__MODULE__{}, attrs :: map()) :: Ecto.Changeset.t()
      def changeset(message, attrs) do
        ValueObject.__changeset__(message, attrs)
      end

      def type, do: :map

      def cast(value) when is_struct(value, __MODULE__), do: {:ok, value}

      def cast(value) when is_map(value) do
        with {:error, _} <- new(value), do: :error
      end

      def cast(_), do: :error

      def load(data) when is_map(data) do
        with {:error, _} <- new(data), do: :error
      end

      def load(_), do: :error

      def dump(value) when is_struct(value, __MODULE__), do: {:ok, Map.from_struct(value)}
      def dump(_), do: :error

      defoverridable new: 1, new!: 1, changeset: 2
    end
  end

  defmacro __before_compile__(env) do
    enforced_keys = get_enforced_keys(env)

    quote unquote: false, bind_quoted: [enforced_keys: enforced_keys] do
      def __enforced_keys__ do
        unquote(enforced_keys)
      end

      for the_key <- enforced_keys do
        def __enforced_keys__?(unquote(the_key)) do
          true
        end
      end

      def __enforced_keys__?(_) do
        false
      end
    end
  end

  defp get_enforced_keys(env) do
    enforce_keys = Module.get_attribute(env.module, :enforce_keys) || []
    enforce_keys ++ get_primary_key_name(env)
  end

  defp get_primary_key_name(env) do
    case Module.get_attribute(env.module, :primary_key) do
      {field_name, _, _} -> [field_name]
      _ -> []
    end
  end

  def __new__(struct_module, attrs) do
    struct_module
    |> apply_changeset(attrs)
    |> Changeset.apply_action(:new)
  end

  def __new__!(struct_module, attrs) do
    struct_module
    |> apply_changeset(attrs)
    |> Changeset.apply_action!(:new)
  end

  def __changeset__(%struct_module{} = message, attrs) do
    embeds = struct_module.__schema__(:embeds)
    fields = struct_module.__schema__(:fields)

    changeset =
      message
      |> Changeset.cast(attrs, fields -- embeds)
      |> Changeset.validate_required(struct_module.__enforced_keys__() -- embeds)

    Enum.reduce(
      embeds,
      changeset,
      &cast_embed(&1, &2, struct_module, attrs)
    )
  end

  defp cast_embed(field, changeset, struct_module, attrs) do
    case is_struct(attrs[field]) do
      false ->
        Changeset.cast_embed(changeset, field, required: struct_module.__enforced_keys__?(field))

      true ->
        # credo:disable-for-next-line Credo.Check.Design.TagTODO
        # TODO: Validate that the struct is of the correct type.
        #   It may be the case that you passed a completely different struct as the value. We could `cast_embed`
        #   always and fix the `Changeset.cast(attrs, fields -- embeds)` by converting the `attrs` into a map. But it
        #   would be a bit more expensive since it will run the casting for a field that was already casted.
        #   Checking the struct types MAY be enough but taking into consideration `embeds_many` could complicated
        #   things. For now, we'll just assume that the user knows what they're doing.
        Changeset.put_change(changeset, field, attrs[field])
    end
  end

  defp apply_changeset(struct_module, attrs) do
    struct(struct_module)
    |> struct_module.changeset(attrs)
  end
end