lib/typed_struct_ctor.ex

defmodule TypedStructCtor do
  @external_resource "README.md"
  @moduledoc "README.md"
             |> File.read!()
             |> String.split("<!-- @moduledoc -->")
             |> Enum.fetch!(1)

  use TypedStruct.Plugin
  use Ecto.Schema
  import Ecto.Changeset

  defmacro init(opts) do
    quote do
      Module.register_attribute(__MODULE__, :required?, accumulate: false)
      Module.register_attribute(__MODULE__, :all_fields, accumulate: true)
      Module.register_attribute(__MODULE__, :default_apply_fields, accumulate: true)
      Module.register_attribute(__MODULE__, :required_fields, accumulate: true)
      Module.register_attribute(__MODULE__, :non_mapped_fields, accumulate: true)

      globally_required = Keyword.get(unquote(opts), :required, true)
      Module.put_attribute(__MODULE__, :required?, globally_required)
    end
  end

  @doc false
  def field(name, type, opts, env) do
    quote bind_quoted: [name: name, type: Macro.escape(type), opts: Macro.escape(opts), env: Macro.escape(env)] do
      TypedStructCtor.__field__(name, type, opts, env)
    end
  end

  def __field__(name, _type, opts, %Macro.Env{module: mod}) do
    Module.put_attribute(mod, :all_fields, {name, true})

    if default_apply = Keyword.get(opts, :default_apply) do
      Module.put_attribute(mod, :default_apply_fields, {name, default_apply})
    end

    if !Keyword.get(opts, :mappable?, true) do
      Module.put_attribute(mod, :non_mapped_fields, {name, true})
    end

    globally_required = Module.get_attribute(mod, :required?)

    if Keyword.get(opts, :required, globally_required) do
      Module.put_attribute(mod, :required_fields, {name, opts})
    end
  end

  def after_definition(_opts) do
    quote unquote: false do
      def __defaults__, do: @default_apply_fields |> Enum.reverse()
      def __required__, do: @required_fields |> Keyword.keys() |> Enum.reverse()
      def __not_mapped__, do: @non_mapped_fields |> Keyword.keys() |> Enum.reverse()
      def __all__, do: @all_fields |> Keyword.keys() |> Enum.reverse()

      Module.delete_attribute(__MODULE__, :required?)
      Module.delete_attribute(__MODULE__, :all_fields)
      Module.delete_attribute(__MODULE__, :default_apply_fields)
      Module.delete_attribute(__MODULE__, :required_fields)
      Module.delete_attribute(__MODULE__, :non_mapped_fields)

      def new(), do: new(%{})

      def new(attrs), do: TypedStructCtor.do_new(__ENV__.module, attrs)

      def new!(), do: new!(%{})

      def new!(attrs) do
        case new(attrs) do
          {:ok, val} -> val
          {:error, :attributes_must_be_a_map} -> raise "Invalid #{__ENV__.module}.new!(): Attributes must be a map}"
          {:error, cs} -> raise "Invalid #{__ENV__.module}.new!(): #{inspect(cs.errors)}"
        end
      end

      def from(base_struct), do: from(base_struct, %{})

      def from(base_struct, attrs), do: TypedStructCtor.do_from(__ENV__.module, base_struct, attrs)

      def from!(base_struct), do: from!(base_struct, %{})

      def from!(base_struct, attrs) do
        case from(base_struct, attrs) do
          {:ok, val} -> val
          {:error, :attributes_must_be_a_map} -> raise "Invalid #{__ENV__.module}.from!(): Attributes must be a map}"
          {:error, cs} -> raise "Invalid #{__MODULE__}.from!(): #{inspect(cs.errors)}"
        end
      end
    end
  end

  @spec new() :: {:ok, struct()} | {:error, Ecto.Changeset.t()}
  @doc "Create a new struct with default values"
  def new(), do: nil

  @spec new(attrs :: map()) :: {:ok, struct()} | {:error, Ecto.Changeset.t()}
  @doc """
  Create a new struct where values from keys in the provided attributes map are copied
  to like-named fields in the struct.

  * Provided values will be cast to the appropriate field type.
  * Fields in the newly constructed struct that are not in the provided map will
    be set to their default values.
  * Required fields that are nil will result in a Ecto.Changeset error
  """
  def new(_attrs), do: nil

  @spec new!() :: struct()
  @doc "Create a new struct with default values.  Raises if the new struct cannot be validated."
  def new!(), do: nil

  @spec new!(attrs :: map()) :: struct()
  @doc """
  Create a new struct where values from keys in the provided attributes map are copied
  to like-named fields in the struct.  Raises if the new struct cannot be validated.

  * Provided values will be cast to the appropriate field type.
  * Fields in the newly constructed struct that are not in the provided map will
    be set to their default values.
  * Any cast error or missing required field will result in a raise
  """
  def new!(_attrs), do: nil

  @spec from(base_struct :: struct()) :: {:ok, struct()} | {:error, Ecto.Changeset.t()}
  @doc """
  Create a new struct where values from fields in the provided struct are copied.

  The `from/1` constructor is useful for event driven systems where it is common to create
  a new event from a given "triggering" event

  * Provided values from `base_struct` will be cast to the appropriate field type.
  * Fields in the newly constructed struct that are not provided map will
    be set to their default values.
  * Required fields that are nil will result in a Ecto.Changeset error
  """
  def from(_base_struct), do: nil

  @spec from(base_struct :: struct(), attrs :: map()) :: {:ok, struct()} | {:error, Ecto.Changeset.t()}
  @doc """
  Create a new struct where values from fields in the provided struct are copied, then values
  from the provided attributes map are copied to like-named fields in the struct; overwriting
  any values copied from the base_struct.

  The `from/2` constructor is useful for event driven systems where it is common to create
  a new event from a given "triggering" event

  * Provided values from `base_struct` will be cast to the appropriate field type.
  * Fields in the newly constructed struct that are not provided map will
    be set to their default values.
  * Required fields that are nil will result in a Ecto.Changeset error
  """
  def from(_base_struct, _attrs), do: nil

  @spec from!(base_struct :: struct()) :: struct()
  @doc """
  Create a new struct where values from fields in the provided struct are copied.  Raising if the new struct
  cannot be validated.

  The `from!/1` constructor is useful for event driven systems where it is common to create
  a new event from a given "triggering" event

  * Provided values from `base_struct` will be cast to the appropriate field type.
  * Fields in the newly constructed struct that are not provided map will
    be set to their default values.
  * Any cast error or missing required field will result in a `raise`
  """
  def from!(_base_struct), do: nil

  @spec from!(base_struct :: struct(), attrs :: map()) :: struct()
  @doc """
  Create a new struct where values from fields in the provided struct are copied, then values
  from the provided attributes map are copied to like-named fields in the struct; overwriting
  any values copied from the base_struct.  Raising if the new struct cannot be validated.

  The `from!/2` constructor is useful for event driven systems where it is common to create
  a new event from a given "triggering" event

  * Provided values from `base_struct` will be cast to the appropriate field type.
  * Fields in the newly constructed struct that are not provided map will
    be set to their default values.
  * Any cast error or missing required field will result in a `raise`
  """
  def from!(_base_struct, _attrs), do: nil

  @doc false
  def do_new(mod, attrs) when not is_struct(attrs) and is_map(attrs) do
    mod.__struct__()
    |> cast(attrs, mod.__all__())
    |> TypedStructCtor.apply_defaults(mod.__defaults__())
    |> validate_required(mod.__required__())
    |> apply_action(:new)
  end

  def do_new(_mod, _attrs), do: {:error, :attributes_must_be_a_map}

  @doc false
  def do_from(mod, base_struct, attrs) when is_struct(base_struct) do
    attrs =
      base_struct
      |> Map.from_struct()
      |> Map.drop(mod.__not_mapped__())
      |> Map.merge(attrs)

    TypedStructCtor.do_new(mod, attrs)
  end

  def do_from(_mod, _base_struct, _attrs), do: {:error, :base_struct_must_be_a_struct}

  # For each field that has a default_apply, apply the result of that function IF the field is not already changed/set
  @doc false
  def apply_defaults(%Ecto.Changeset{} = changeset, defaults) do
    apply_default = fn
      {m, f, a} -> apply(m, f, a)
      {f, a} -> apply(f, a)
    end

    Enum.reduce(defaults, changeset, fn {field, apply}, changeset ->
      if get_change(changeset, field) == nil do
        defaulted_value = apply_default.(apply)
        cast(changeset, %{field => defaulted_value}, [field])
      else
        changeset
      end
    end)
  end
end