Skip to main content

lib/trogon/ecto/value_object.ex

defmodule Trogon.Ecto.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`

  ## Imports

  `use Trogon.Ecto.ValueObject` imports `PolymorphicEmbed.polymorphic_embeds_one/2`
  and `PolymorphicEmbed.polymorphic_embeds_many/2` so they can be used inside
  `embedded_schema/1`.

  ## Usage

  ```elixir
  defmodule MyValueObject do
    use Trogon.Ecto.ValueObject

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

  ## Overridable

  - `validate/2` to add custom validation to the existing `changeset/2` without overriding the whole `changeset/2`
    function.

      ```elixir
      defmodule MyValueObject do
        use Trogon.Ecto.ValueObject

        embedded_schema do
          field :amount, :integer
        end

        def validate(changeset, _attrs) do
          Ecto.Changeset.validate_number(changeset, :amount, greater_than: 0)
        end
      end
      ```

  - `changeset/2` returns an `t:Ecto.Changeset.t/0` for a given value object struct.

      > #### Overriding Changeset {: .warning}
      >
      > Be careful when overriding `changeset/2` because the default
      > implementation takes care of `cast`, `validate_required` the
      > `@enforced_keys` and nested embeds. You may want to call
      > `Trogon.Ecto.ValueObject.changeset/2` to have such features.
      >
      > If you only need to extend the changeset, you can override the
      > `validate/2` function instead.
  """
  @spec __using__(opts :: Keyword.t()) :: Macro.t()
  defmacro __using__(_opts \\ []) do
    quote generated: true do
      alias Trogon.Ecto.ValueObject
      alias Ecto.Changeset

      use Ecto.Schema
      use Ecto.Type
      import PolymorphicEmbed, only: [polymorphic_embeds_one: 2, polymorphic_embeds_many: 2]

      @primary_key false

      @before_compile Trogon.Ecto.ValueObject

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

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

      @doc false
      @spec validate(Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
      def validate(%Ecto.Changeset{} = changeset, _attrs) do
        changeset
      end

      @doc false
      @spec changeset(message :: %__MODULE__{}, attrs :: map() | %__MODULE__{}) :: Ecto.Changeset.t()
      def changeset(_message, attrs) when is_struct(attrs, __MODULE__) do
        Ecto.Changeset.change(attrs)
      end

      def changeset(message, attrs) do
        message
        |> Trogon.Ecto.ValueObject.changeset(attrs)
        |> validate(attrs)
      end

      def type, do: :map

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

      def cast(%other{}) do
        {:error, message: "expected %#{inspect(__MODULE__)}{}, got %#{inspect(other)}{}"}
      end

      def cast(value) when is_map(value) do
        case new(value) do
          {:ok, v} -> {:ok, v}
          {:error, _changeset} -> {:error, message: "is invalid"}
        end
      end

      def cast(_), do: :error

      def load(value) when is_struct(value, __MODULE__), do: {:ok, value}
      def load(%_other{}), do: :error

      def load(data) when is_map(data) do
        {:ok, Ecto.embedded_load(__MODULE__, data, :json)}
      rescue
        _ -> :error
      end

      def load(_), do: :error

      def dump(value) when is_struct(value, __MODULE__), do: {:ok, Ecto.embedded_dump(value, :json)}
      def dump(_), do: :error

      def embed_as(_format), do: :dump

      defoverridable new: 1,
                     new!: 1,
                     changeset: 2,
                     validate: 2,
                     type: 0,
                     cast: 1,
                     load: 1,
                     dump: 1,
                     embed_as: 1
    end
  end

  defmacro __before_compile__(env) do
    enforced_keys = get_enforced_keys(env)
    {polymorphic_embeds, polymorphic_embeds_many} = get_polymorphic_embeds(env)
    field_names = get_field_names(env)
    embed_names = get_embed_names(env)
    all_embeds = embed_names ++ polymorphic_embeds
    cast_fields = field_names -- all_embeds
    required_fields = enforced_keys -- all_embeds

    quote unquote: false,
          bind_quoted: [
            enforced_keys: enforced_keys,
            polymorphic_embeds: polymorphic_embeds,
            polymorphic_embeds_many: polymorphic_embeds_many,
            cast_fields: cast_fields,
            required_fields: required_fields
          ] 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

      def __polymorphic_embeds__ do
        unquote(polymorphic_embeds)
      end

      def __polymorphic_embeds_many__ do
        unquote(polymorphic_embeds_many)
      end

      def __cast_fields__ do
        unquote(cast_fields)
      end

      def __required_fields__ do
        unquote(required_fields)
      end
    end
  end

  defp get_enforced_keys(env) do
    enforce_keys = Module.get_attribute(env.module, :enforce_keys) || []
    Enum.uniq(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, _type, opts} ->
        if Keyword.get(opts, :autogenerate, false), do: [], else: [field_name]

      _ ->
        []
    end
  end

  defp get_polymorphic_embeds(env) do
    ecto_fields = Module.get_attribute(env.module, :ecto_fields) || []

    {all, many} =
      ecto_fields
      |> Enum.reverse()
      |> Enum.reduce({[], []}, &classify_polymorphic_embed/2)

    {Enum.reverse(all), Enum.reverse(many)}
  end

  defp classify_polymorphic_embed(
         {name, {{:array, {:parameterized, {PolymorphicEmbed, _}}}, _writable}},
         {all, many}
       ) do
    {[name | all], [name | many]}
  end

  defp classify_polymorphic_embed(
         {name, {{:parameterized, {PolymorphicEmbed, _}}, _writable}},
         {all, many}
       ) do
    {[name | all], many}
  end

  defp classify_polymorphic_embed(_field, acc), do: acc

  defp get_field_names(env) do
    ecto_fields = Module.get_attribute(env.module, :ecto_fields) || []
    for {name, _type} <- Enum.reverse(ecto_fields), do: name
  end

  defp get_embed_names(env) do
    ecto_embeds = Module.get_attribute(env.module, :ecto_embeds) || []
    for {name, _embed} <- Enum.reverse(ecto_embeds), do: name
  end

  @doc """
  Creates a value object struct for the given module and attributes.

  This function applies the changeset validation logic defined in the value object
  module and returns either `{:ok, struct}` on success or `{:error, changeset}`
  when validation fails.

  ## Parameters

  - `struct_module` - The value object module that uses `Trogon.Ecto.ValueObject`
  - `attrs` - A map of attributes to create the value object with

  ## Examples

  Creating a simple value object:

      iex> Trogon.Ecto.ValueObject.new(Trogon.Ecto.TestSupport.MessageOne, %{title: "Hello"})
      {:ok, %Trogon.Ecto.TestSupport.MessageOne{title: "Hello"}}

  Creating a value object with validation:

      iex> Trogon.Ecto.ValueObject.new(Trogon.Ecto.TestSupport.TransferableMoney, %{amount: 100, currency: :USD})
      {:ok, %Trogon.Ecto.TestSupport.TransferableMoney{amount: 100, currency: :USD}}

  Validation failure example:

      iex> {:error, changeset} = Trogon.Ecto.ValueObject.new(Trogon.Ecto.TestSupport.TransferableMoney, %{amount: -5, currency: :USD})
      iex> changeset.valid?
      false

  Missing required field:

      iex> {:error, changeset} = Trogon.Ecto.ValueObject.new(Trogon.Ecto.TestSupport.MyValueObject, %{amount: 25})
      iex> changeset.valid?
      false

  Passing an own-type struct returns it unchanged (value objects are immutable):

      iex> Trogon.Ecto.ValueObject.new(Trogon.Ecto.TestSupport.MessageOne, %Trogon.Ecto.TestSupport.MessageOne{title: "Hello"})
      {:ok, %Trogon.Ecto.TestSupport.MessageOne{title: "Hello"}}

  Passing a foreign struct raises an `ArgumentError`.
  """
  @spec new(struct_module :: atom(), attrs :: map() | struct()) ::
          {:ok, struct()} | {:error, Ecto.Changeset.t()}
  def new(struct_module, attrs) when is_atom(struct_module) and is_struct(attrs, struct_module) do
    {:ok, attrs}
  end

  def new(struct_module, %other{}) when is_atom(struct_module) do
    raise_unexpected_attrs(struct_module, other)
  end

  def new(struct_module, attrs) when is_atom(struct_module) and is_map(attrs) do
    struct_module
    |> apply_changeset(attrs)
    |> Changeset.apply_action(:new)
  end

  @doc """
  Creates a value object struct for the given module and attributes, raising on validation errors.

  This function is similar to `new/2` but raises an `Ecto.InvalidChangesetError`
  instead of returning an error tuple when validation fails.

  ## Parameters

  - `struct_module` - The value object module that uses `Trogon.Ecto.ValueObject`
  - `attrs` - A map of attributes to create the value object with

  ## Examples

  Creating a simple value object:

      iex> Trogon.Ecto.ValueObject.new!(Trogon.Ecto.TestSupport.MessageOne, %{title: "Hello"})
      %Trogon.Ecto.TestSupport.MessageOne{title: "Hello"}

  Creating a value object with validation:

      iex> Trogon.Ecto.ValueObject.new!(Trogon.Ecto.TestSupport.TransferableMoney, %{amount: 100, currency: :USD})
      %Trogon.Ecto.TestSupport.TransferableMoney{amount: 100, currency: :USD}

  Validation failure raises an exception:

      iex> try do
      ...>   Trogon.Ecto.ValueObject.new!(Trogon.Ecto.TestSupport.TransferableMoney, %{amount: -5, currency: :USD})
      ...> rescue
      ...>   Ecto.InvalidChangesetError -> :error_raised
      ...> end
      :error_raised

  Missing required field raises an exception:

      iex> try do
      ...>   Trogon.Ecto.ValueObject.new!(Trogon.Ecto.TestSupport.MyValueObject, %{amount: 25})
      ...> rescue
      ...>   Ecto.InvalidChangesetError -> :error_raised
      ...> end
      :error_raised

  Passing an own-type struct returns it unchanged:

      iex> Trogon.Ecto.ValueObject.new!(Trogon.Ecto.TestSupport.MessageOne, %Trogon.Ecto.TestSupport.MessageOne{title: "Hello"})
      %Trogon.Ecto.TestSupport.MessageOne{title: "Hello"}

  Passing a foreign struct raises an `ArgumentError`.
  """
  @spec new!(struct_module :: atom(), attrs :: map() | struct()) :: struct()
  def new!(struct_module, attrs) when is_atom(struct_module) and is_struct(attrs, struct_module) do
    attrs
  end

  def new!(struct_module, %other{}) when is_atom(struct_module) do
    raise_unexpected_attrs(struct_module, other)
  end

  def new!(struct_module, attrs) when is_atom(struct_module) and is_map(attrs) do
    struct_module
    |> apply_changeset(attrs)
    |> Changeset.apply_action!(:new)
  end

  @doc """
  Returns an `t:Ecto.Changeset.t/0` for a given value object struct.

  It reads the `@enforced_keys` from the struct and validates the required
  fields. Also, it casts the embeds. It is useful when you override the
  `changeset/2` function in your value object.

  ## Examples

  ```elixir
  defmodule MyValueObject do
    use Trogon.Ecto.ValueObject

    @enforce_keys [:title, :amount]
    embedded_schema do
      field :title, :string
      field :amount, :integer
    end

    def changeset(message, attrs) do
      message
      |> ValueObject.changeset(attrs)
      |> Changeset.validate_number(:amount, greater_than: 0)
    end
  end
  ```
  """
  def changeset(%struct_module{}, %struct_module{} = attrs), do: Changeset.change(attrs)

  def changeset(%struct_module{}, %other{}) when struct_module != other do
    raise_unexpected_attrs(struct_module, other)
  end

  def changeset(%struct_module{} = message, attrs) do
    message
    |> Changeset.cast(attrs, struct_module.__cast_fields__())
    |> Changeset.validate_required(struct_module.__required_fields__())
    |> cast_embeds(struct_module.__schema__(:embeds), struct_module)
    |> cast_polymorphic_embeds(struct_module.__polymorphic_embeds__(), struct_module)
    |> validate_required_polymorphic_embeds_many(struct_module)
  end

  defp cast_polymorphic_embeds(changeset, polymorphic_embeds, struct_module) do
    Enum.reduce(
      polymorphic_embeds,
      changeset,
      &cast_polymorphic_embed(&1, &2, struct_module)
    )
  end

  defp validate_required_polymorphic_embeds_many(changeset, struct_module) do
    Enum.reduce(
      struct_module.__polymorphic_embeds_many__(),
      changeset,
      &validate_required_polymorphic_embed_many(&1, &2, struct_module)
    )
  end

  defp validate_required_polymorphic_embed_many(field, changeset, struct_module) do
    if struct_module.__enforced_keys__?(field) and Changeset.get_field(changeset, field) == [] do
      Changeset.add_error(changeset, field, "can't be blank", validation: :required)
    else
      changeset
    end
  end

  defp cast_embeds(changeset, embeds, struct_module) do
    Enum.reduce(
      embeds,
      changeset,
      &cast_embed(&1, &2, struct_module)
    )
  end

  defp cast_embed(field, changeset, struct_module) do
    Changeset.cast_embed(changeset, field, required: struct_module.__enforced_keys__?(field))
  end

  defp cast_polymorphic_embed(field, changeset, struct_module) do
    PolymorphicEmbed.cast_polymorphic_embed(changeset, field, required: struct_module.__enforced_keys__?(field))
  end

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

  defp raise_unexpected_attrs(struct_module, other) do
    raise ArgumentError,
          "expected attrs to be a map or %#{inspect(struct_module)}{}, got %#{inspect(other)}{}"
  end
end