lib/parameter_ecto_changeset.ex

defmodule Parameter.Ecto.Changeset do
  @moduledoc "README.md"
             |> File.read!()
             |> String.split("<!-- MDOC !-->")
             |> Enum.fetch!(1)
  alias Parameter.Field

  @spec cast_params(module(), map()) :: Ecto.Changeset.t()
  def cast_params(schema, params) when is_map(params) do
    types = cast_types_from_schema(schema)
    Ecto.Changeset.cast({schema.__struct__, types}, params, Map.keys(types))
  end

  def cast_assoc_params(changeset, key, with: changeset_func) do
    field_name = to_string(key)

    param_value = changeset.params[field_name]

    field = changeset.data.__struct__.__param__(:field, name: key)
    embed = mount_embed_type(changeset, field)
    types = Map.put(changeset.types, key, embed)
    changeset = %{changeset | types: types}

    run_cast_assoc_params(changeset, field, param_value, changeset_func)
  end

  defp cast_types_from_schema(schema) do
    Enum.map(schema.__param__(:fields), &flatten_params/1)
    |> Enum.reject(&(&1 == nil))
    |> Enum.into(%{})
  end

  defp flatten_params(%Field{type: {:has_one, _inner_module}}) do
    nil
  end

  defp flatten_params(%Field{type: {:has_many, _inner_module}}) do
    nil
  end

  defp flatten_params(%Field{name: name, type: type}) do
    if Ecto.Type.base?(type) do
      {name, type}
    else
      {name, :any}
    end
  end

  defp run_cast_assoc_params(changeset, _field, nil, _changeset_func) do
    changeset
  end

  defp run_cast_assoc_params(
         changeset,
         %Field{name: field_name, type: {:has_many, _inner_module}},
         param_values,
         changeset_func
       )
       when is_list(param_values) do
    Enum.reduce(param_values, changeset, fn param_value, changeset ->
      nested_changeset = changeset_func.(param_value)

      nested_changeset = %{nested_changeset | action: :insert}

      changeset =
        if changeset.valid? and nested_changeset.valid? do
          changeset
        else
          %{changeset | valid?: false}
        end

      nested_changes = Map.get(changeset.changes, field_name, [])

      nested_changes = nested_changes ++ [nested_changeset]

      changes = Map.put(changeset.changes, field_name, nested_changes)
      %{changeset | repo_opts: [{:force, true}], changes: changes}
    end)
  end

  defp run_cast_assoc_params(changeset, %Field{name: field_name}, param_value, changeset_func) do
    nested_changeset = changeset_func.(param_value)

    nested_changeset = %{nested_changeset | action: :insert}

    changeset =
      if changeset.valid? and nested_changeset.valid? do
        changeset
      else
        %{changeset | valid?: false}
      end

    changes = Map.put(changeset.changes, field_name, nested_changeset)

    %{changeset | repo_opts: [{:force, true}], changes: changes}
  end

  defp mount_embed_type(changeset, %Field{name: name, type: {:has_one, inner_module}}) do
    {:embed,
     %Ecto.Embedded{
       cardinality: :one,
       field: name,
       owner: changeset.data.__struct__,
       related: inner_module,
       on_cast: nil,
       on_replace: :raise,
       unique: true,
       ordered: true
     }}
  end

  defp mount_embed_type(changeset, %Field{name: name, type: {:has_many, inner_module}}) do
    {:embed,
     %Ecto.Embedded{
       cardinality: :many,
       field: name,
       owner: changeset.data.__struct__,
       related: inner_module,
       on_cast: nil,
       on_replace: :raise,
       unique: true,
       ordered: true
     }}
  end
end