lib/common_changes.ex

defmodule EctoShorts.CommonChanges do
  @moduledoc """
  This module is responsible for determining put/cast assoc as well as creating and updating model relations
  """
  import Logger, only: [warn: 1]

  import Ecto.Changeset, only: [
    get_field: 2,
    put_assoc: 4,
    cast_assoc: 2,
    cast_assoc: 3
  ]

  alias EctoShorts.{Actions, SchemaHelpers, Config}
  alias Ecto.Changeset

  @doc "Run's changeset function if when function returns true"
  @spec put_when(
    Changeset.t,
    ((Changeset.t) -> boolean),
    ((Changeset.t) -> Changeset.t)
  ) :: Changeset.t
  def put_when(changeset, when_func, change_func) do
    if when_func.(changeset) do
      change_func.(changeset)
    else
      changeset
    end
  end

  @doc "Checks if field on changeset is empty list in data or changes"
  @spec changeset_field_empty?(Changeset.t, atom) :: boolean
  def changeset_field_empty?(changeset, key) do
    get_field(changeset, key) === []
  end

  @doc "Checks if field on changeset is nil in data or changes"
  @spec changeset_field_nil?(Changeset.t, atom) :: boolean
  def changeset_field_nil?(changeset, key) do
    is_nil(get_field(changeset, key))
  end

  @doc """
  This function is the primary use function
  Preloads changeset assoc if change is made and then and put_or_cast's it


  ## Example

    iex> CommonChanges.preload_change_assoc(changeset, :my_relation)
    iex> CommonChanges.preload_change_assoc(changeset, :my_relation, repo: MyApp.OtherRepo)
    iex> CommonChanges.preload_change_assoc(changeset, :my_relation, required: true)
  """
  @spec preload_change_assoc(Changeset.t, atom, Keyword.t) :: Changeset.t
  @spec preload_change_assoc(Changeset.t, atom) :: Changeset.t
  def preload_change_assoc(changeset, key, opts) do
    if Map.has_key?(changeset.params, Atom.to_string(key)) do
      changeset
        |> preload_changeset_assoc(key, opts)
        |> put_or_cast_assoc(key, opts)
    else
      cast_assoc(changeset, key, opts)
    end
  end

  def preload_change_assoc(changeset, key) do
    if Map.has_key?(changeset.params, Atom.to_string(key)) do
      changeset
        |> preload_changeset_assoc(key)
        |> put_or_cast_assoc(key)
    else
      cast_assoc(changeset, key)
    end
  end

  @doc "Preloads a changesets association"
  @spec preload_changeset_assoc(Changeset.t, atom) :: Changeset.t
  @spec preload_changeset_assoc(Changeset.t, atom, list(integer)) :: Changeset.t
  def preload_changeset_assoc(changeset, key, opts \\ [])

  def preload_changeset_assoc(changeset, key, opts) do
    opts = Keyword.merge(default_opts(), opts)

    if opts[:ids] do
      schema = changeset_relationship_schema(changeset, key)

      preloaded_data = Actions.all(schema, %{ids: opts[:ids]}, repo: opts[:repo])

      Map.update!(changeset, :data, &Map.put(&1, key, preloaded_data))
    else
      Map.update!(changeset, :data, &opts[:repo].preload(&1, key, opts))
    end
  end

  defp changeset_relationship_schema(changeset, key) do
    if Map.has_key?(changeset.types, key) and relationship_exists?(changeset.types[key]) do
      changeset.types
        |> Map.get(key)
        |> elem(1)
        |> Map.get(:queryable)
    else
      warn "Changeset relationship for CommonChanges.put_or_cast_assoc #{key} was not found"

      changeset
    end
  end

  @spec put_or_cast_assoc(Changeset.t, atom) :: Changeset.t
  @spec put_or_cast_assoc(Changeset.t, atom, Keyword.t) :: Changeset.t
  @doc """
  Determines put or cast on association with some special magic

  If you pass a many to many relation only a list of id's it will count that as a `member_update` and remove or add members to the relations list

  E.G. User many_to_many Fruit

  This would update the user to have only fruits with id 1 and 3
  ```elixir
  CommonChanges.put_or_cast_assoc(change(user, fruits: [%{id: 1}, %{id: 3}]), :fruits)
  ```
  """
  def put_or_cast_assoc(changeset, key, opts \\ []) do
    params_data = Map.get(changeset.params, Atom.to_string(key))

    find_method_and_put_or_cast(changeset, key, params_data, opts)
  end

  defp find_method_and_put_or_cast(changeset, key, params_data, opts) when is_list(params_data) do
    cond do
      is_nil(params_data) -> cast_assoc(changeset, key, opts)

      SchemaHelpers.all_schemas?(params_data) -> put_assoc(
        changeset,
        key,
        params_data,
        opts
      )

      member_update?(params_data) ->
        schema = changeset_relationship_schema(changeset, key)
        data = Actions.all(schema, ids: data_ids(params_data))

        put_assoc(changeset, key, data, opts)

      SchemaHelpers.any_created?(params_data) ->
        changeset
          |> preload_changeset_assoc(
            key,
            Keyword.put(opts, :ids, params_data |> data_ids |> Enum.reject(&is_nil/1))
          )
          |> cast_assoc(key, opts)

      true -> cast_assoc(changeset, key, opts)
    end
  end

  defp find_method_and_put_or_cast(changeset, key, param_data, opts) do
    if SchemaHelpers.schema?(param_data) do
      put_assoc(changeset, key, param_data, opts)
    else
      cast_assoc(changeset, key, opts)
    end
  end

  defp member_update?(schemas) do
    Enum.all?(schemas, fn
      %{id: id} = item when item === %{id: id} -> true
      _ -> false
    end)
  end

  defp data_ids(data), do: Enum.map(data, &Map.get(&1, :id))

  defp relationship_exists?({:assoc, _}), do: true
  defp relationship_exists?(_), do: false

  def default_opts, do: [repo: Config.repo()]
end