lib/ash/changeset/managed_relationship_helpers.ex

defmodule Ash.Changeset.ManagedRelationshipHelpers do
  @moduledoc """
  Tools for introspecting managed relationships.

  Extensions can use this to look at an argument that will be passed
  to a `manage_relationship` change and determine what their behavior
  should be. For example, AshAdmin uses these to find out what kind of
  nested form it should offer for each argument that manages a relationship.
  """

  def sanitize_opts(relationship, opts) do
    is_many_to_many = relationship.type == :many_to_many
    join_keys = opts[:join_keys] || []

    [
      on_no_match: :ignore,
      on_missing: :ignore,
      on_match: :ignore,
      on_lookup: :ignore
    ]
    |> Keyword.merge(opts)
    |> Keyword.update!(:on_no_match, fn
      :create when is_many_to_many ->
        create = primary_action_name!(relationship.destination, :create)
        join_create = primary_action_name!(relationship.through, :create)
        {:create, create, join_create, join_keys}

      {:create, create} when is_many_to_many ->
        join_create = primary_action_name!(relationship.through, :create)
        {:create, create, join_create, join_keys}

      {:create, create, join_create} when is_many_to_many ->
        {:create, create, join_create, join_keys}

      :create ->
        create = primary_action_name!(relationship.destination, :create)
        {:create, create}

      other ->
        other
    end)
    |> Keyword.update!(:on_missing, fn
      :unrelate when is_many_to_many ->
        join_destroy = primary_action_name!(relationship.through, :destroy)
        {:unrelate, join_destroy}

      :unrelate when relationship.type in [:has_many, :has_one] ->
        update = primary_action_name!(relationship.destination, :update)
        {:unrelate, update}

      :unrelate ->
        {:unrelate, nil}

      :destroy when is_many_to_many ->
        destroy = primary_action_name!(relationship.destination, :destroy)
        join_destroy = primary_action_name!(relationship.through, :destroy)
        {:destroy, destroy, join_destroy}

      {:destroy, destroy} when is_many_to_many ->
        join_destroy = primary_action_name!(relationship.through, :destroy)
        {:destroy, destroy, join_destroy}

      :destroy ->
        destroy = primary_action_name!(relationship.destination, :destroy)
        {:destroy, destroy}

      other ->
        other
    end)
    |> Keyword.update!(:on_match, fn
      :update when is_many_to_many ->
        update = primary_action_name!(relationship.destination, :update)
        join_update = primary_action_name!(relationship.through, :update)
        {:update, update, join_update, join_keys}

      {:update, update} when is_many_to_many ->
        join_update = primary_action_name!(relationship.through, :update)
        {:update, update, join_update, join_keys}

      {:update, update, join_update} when is_many_to_many ->
        {:update, update, join_update, join_keys}

      :update ->
        update = primary_action_name!(relationship.destination, :update)
        {:update, update}

      :update_join ->
        join_update = primary_action_name!(relationship.through, :update)
        {:update, nil, join_update, join_keys}

      {:update_join, join_update} ->
        {:update, nil, join_update, join_keys}

      {:update_join, join_update, join_keys} ->
        {:update, nil, join_update, join_keys}

      :unrelate when is_many_to_many ->
        join_destroy = primary_action_name!(relationship.through, :destroy)
        {:unrelate, join_destroy}

      :unrelate when relationship.type in [:has_many, :has_one] ->
        update = primary_action_name!(relationship.destination, :update)
        {:unrelate, update}

      :unrelate ->
        {:unrelate, nil}

      :destroy when is_many_to_many ->
        join_destroy = primary_action_name!(relationship.through, :destroy)
        {:destroy, join_destroy}

      :destroy ->
        destroy = primary_action_name!(relationship.destination, :destroy)
        {:destroy, destroy}

      other ->
        other
    end)
    |> Keyword.update!(:on_lookup, fn
      key when is_many_to_many and key in [:relate, :relate_and_update] ->
        join_create = primary_action_name!(relationship.through, :create)
        read = primary_action_name!(relationship.destination, :read)
        {key, join_create, read, join_keys}

      {key, join_create} when is_many_to_many and key in [:relate, :relate_and_update] ->
        read = primary_action_name!(relationship.destination, :read)
        {key, join_create, read, join_keys}

      {key, join_create, read} when is_many_to_many and key in [:relate, :relate_and_update] ->
        {key, join_create, read, join_keys}

      key
      when relationship.type in [:has_many, :has_one] and key in [:relate, :relate_and_update] ->
        update = primary_action_name!(relationship.destination, :update)
        read = primary_action_name!(relationship.destination, :read)
        {key, update, read}

      key when key in [:relate, :relate_and_update] ->
        read = primary_action_name!(relationship.destination, :read)
        {key, nil, read}

      {key, update} when key in [:relate, :relate_and_update] ->
        read = primary_action_name!(relationship.destination, :read)
        {key, update, read}

      other ->
        other
    end)
  end

  def on_match_destination_actions(opts, relationship) do
    opts = sanitize_opts(relationship, opts)

    case opts[:on_match] do
      key when key in [:ignore, :error] ->
        nil

      :no_match ->
        on_no_match_destination_actions(opts, relationship)

      :missing ->
        on_missing_destination_actions(opts, relationship)

      {:update, update, join_update, join_keys} ->
        all([destination(update), join(join_update, join_keys)])

      {:update, update} ->
        all(destination(update))

      {:unrelate, join_destroy} when relationship.type == :many_to_many ->
        all(join(join_destroy, :all))

      {:unrelate, update} when relationship.type in [:has_one, :has_many] ->
        all(destination(update))

      {:unrelate, _nil} ->
        nil

      {:destroy, join_destroy} when relationship.type == :many_to_many ->
        all(join(join_destroy, :all))

      {:destroy, destroy} ->
        all(destination(destroy))
    end
  end

  def on_no_match_destination_actions(opts, relationship) do
    opts = sanitize_opts(relationship, opts)

    case opts[:on_no_match] do
      key when key in [:ignore, :error] ->
        nil

      :match ->
        on_match_destination_actions(opts, relationship)

      {:create, create, join_create, join_keys} ->
        all([destination(create), join(join_create, join_keys)])

      {:create, create} ->
        all(destination(create))
    end
  end

  def on_missing_destination_actions(opts, relationship) do
    opts = sanitize_opts(relationship, opts)

    case opts[:on_missing] do
      key when key in [:ignore, :error] ->
        nil

      {:unrelate, join_destroy} when relationship.type == :many_to_many ->
        all(join(join_destroy, []))

      {:unrelate, update} when relationship.type in [:has_one, :has_many] ->
        all(destination(update))

      {:unrelate, _nil} ->
        nil

      {:destroy, destroy, join_destroy} ->
        all([destination(destroy), join(join_destroy, [])])

      {:destroy, destroy} ->
        all(destination(destroy))
    end
  end

  def on_lookup_update_action(opts, relationship) do
    opts = sanitize_opts(relationship, opts)

    case opts[:on_lookup] do
      :ignore ->
        nil

      {key, join_create, _read, join_keys} when key in [:relate, :relate_and_update] ->
        join(join_create, join_keys)

      {key, update, _read}
      when key in [:relate, :relate_and_update] and relationship.type in [:has_one, :has_many] ->
        destination(update)

      {key, _nil, _read} when key in [:relate, :relate_and_update] ->
        nil
    end
  end

  def on_lookup_read_action(opts, relationship) do
    opts = sanitize_opts(relationship, opts)

    case opts[:on_lookup] do
      :ignore ->
        nil

      {key, _join_create, read, _join_keys} when key in [:relate, :relate_and_update] ->
        destination(read)

      {key, _update, read} when key in [:relate, :relate_and_update] ->
        destination(read)
    end
  end

  defp all(values) do
    case Enum.filter(List.wrap(values), & &1) do
      [] -> nil
      values -> values
    end
  end

  defp destination(nil), do: nil
  defp destination(action), do: {:destination, action}

  defp join(nil, _), do: nil
  defp join(action, keys), do: {:join, action, keys}

  def could_handle_missing?(opts) do
    opts[:on_missing] not in [:ignore, :error]
  end

  def could_lookup?(opts) do
    opts[:on_lookup] != :ignore
  end

  def could_create?(opts) do
    unwrap(opts[:on_no_match]) == :create || opts[:on_match] == :no_match
  end

  def could_update?(opts) do
    opts[:on_match] not in [:ignore, :no_match, :missing]
  end

  def must_load?(opts, must_load_opts \\ []) do
    creating_and_cant_have_related? =
      must_load_opts[:could_be_related_at_creation?] == false &&
        must_load_opts[:action_type] == :create

    allowed_on_no_match_values =
      if creating_and_cant_have_related? do
        [:create, :ignore, :error]
      else
        [:create, :ignore]
      end

    only_creates_or_ignores? =
      creating_and_cant_have_related? ||
        (opts[:on_match] in [:no_match, :ignore] &&
           unwrap(opts[:on_no_match]) in allowed_on_no_match_values)

    on_missing_can_skip_load? =
      if must_load_opts[:could_be_related_at_creation?] == false &&
           must_load_opts[:action_type] == :create do
        true
      else
        opts[:on_missing] == :ignore
      end

    can_skip_load? = on_missing_can_skip_load? && only_creates_or_ignores?

    not can_skip_load?
  end

  defp primary_action_name!(resource, type) do
    Ash.Resource.Info.primary_action!(resource, type).name
  end

  defp unwrap(value) when is_atom(value), do: value
  defp unwrap(tuple) when is_tuple(tuple), do: elem(tuple, 0)
  defp unwrap(value), do: value
end