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