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`.

  It derives from `Jason.Encoder` and also adds some factory functions to create
  structs.

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

      @derive Jason.Encoder
      @primary_key false

      @before_compile OnePiece.Commanded.ValueObject

      @doc """
      Creates a `t:t/0`.
      """
      @spec new(attrs :: map()) :: {:ok, %__MODULE__{}}
      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` command.
      """
      @spec changeset(message :: %__MODULE__{}, attrs :: map()) :: Ecto.Changeset.t()
      def changeset(message, attrs) do
        ValueObject.__changeset__(message, attrs)
      end

      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)
    allowed = struct_module.__schema__(:fields) -- embeds

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

    Enum.reduce(embeds, changeset, fn field, changeset ->
      Changeset.cast_embed(changeset, field, required: struct_module.__enforced_keys__?(field))
    end)
  end

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