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
    [
      on_no_match: :ignore,
      on_missing: :ignore,
      on_match: :ignore,
      on_lookup: :ignore
    ]
    |> Keyword.merge(opts)
    |> Keyword.update!(:on_no_match, fn
      :create when relationship.type == :many_to_many ->
        action = Ash.Resource.Info.primary_action!(relationship.destination, :create)
        join_action = Ash.Resource.Info.primary_action!(relationship.through, :create)
        {:create, action.name, join_action.name, []}

      {:create, action_name} when relationship.type == :many_to_many ->
        join_action = Ash.Resource.Info.primary_action!(relationship.through, :create)
        {:create, action_name, join_action.name, []}

      :create ->
        action = Ash.Resource.Info.primary_action!(relationship.destination, :create)
        {:create, action.name}

      other ->
        other
    end)
    |> Keyword.update!(:on_missing, fn
      :destroy when relationship.type == :many_to_many ->
        action = Ash.Resource.Info.primary_action!(relationship.destination, :destroy)

        join_action = Ash.Resource.Info.primary_action!(relationship.through, :destroy)

        {:destroy, action.name, join_action.name}

      {:destroy, action_name} when relationship.type == :many_to_many ->
        join_action = Ash.Resource.Info.primary_action!(relationship.through, :destroy)

        {:destroy, action_name, join_action.name}

      :destroy ->
        action = Ash.Resource.Info.primary_action!(relationship.destination, :destroy)

        {:destroy, action.name}

      :unrelate ->
        {:unrelate, nil}

      other ->
        other
    end)
    |> Keyword.update!(:on_match, fn
      :update when relationship.type == :many_to_many ->
        update = Ash.Resource.Info.primary_action!(relationship.destination, :update)
        join_update = Ash.Resource.Info.primary_action!(relationship.through, :update)

        {:update, update.name, join_update.name, []}

      {:update, update} when relationship.type == :many_to_many ->
        join_update = Ash.Resource.Info.primary_action!(relationship.through, :update)

        {:update, update, join_update.name, []}

      {:update, update, join_update} when relationship.type == :many_to_many ->
        {:update, update, join_update, []}

      :update ->
        action = Ash.Resource.Info.primary_action!(relationship.destination, :update)

        {:update, action.name}

      :unrelate ->
        {:unrelate, nil}

      :destroy when relationship.type == :many_to_many ->
        action = Ash.Resource.Info.primary_action!(relationship.through, :destroy)
        {:destroy, action.name}

      :destroy ->
        action = Ash.Resource.Info.primary_action!(relationship.destination, :destroy)
        {:destroy, action.name}

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

      {key, action}
      when relationship.type == :many_to_many and
             key in [:relate, :relate_and_update] ->
        {key, action, primary_action_name(relationship.destination, :read)}

      {key, action, read}
      when relationship.type == :many_to_many and
             key in [:relate, :relate_and_update] ->
        {key, action, read}

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

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

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

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

      other ->
        other
    end)
  end

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

    cond do
      opts[:on_match] in [:ignore, :error] ->
        nil

      unwrap(opts[:on_match]) == :unrelate ->
        nil

      opts[:on_match] == :no_match ->
        on_no_match_destination_actions(opts, relationship)

      opts[:on_match] == :missing ->
        on_missing_destination_actions(opts, relationship)

      unwrap(opts[:on_match]) == :destroy && relationship.type == :many_to_many ->
        case opts[:on_match] do
          :destroy ->
            all(join(primary_action_name(relationship.through, :destroy), :all))

          {:destroy, action_name} ->
            all(join(action_name, :all))
        end

      unwrap(opts[:on_match]) == :destroy ->
        case opts[:on_match] do
          :destroy ->
            all(destination(primary_action_name(relationship.destination, :destroy)))

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

      unwrap(opts[:on_match]) == :update ->
        case opts[:on_match] do
          :update ->
            all(destination(primary_action_name(relationship.destination, :update)))

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

          {:update, action_name, join_table_action_name, keys} ->
            all([destination(action_name), join(join_table_action_name, keys)])
        end
    end
  end

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

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

      :match ->
        on_match_destination_actions(opts, relationship)

      :create ->
        all(destination(primary_action_name(relationship.destination, :create)))

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

      {:create, _action_name, join_table_action_name, :all} ->
        all([join(join_table_action_name, :all)])

      {:create, action_name, join_table_action_name, keys} ->
        all([destination(action_name), join(join_table_action_name, keys)])
    end
  end

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

    case opts[:on_missing] do
      :destroy ->
        all(destination(primary_action_name(relationship.destination, :destroy)))

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

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

      _ ->
        nil
    end
  end

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

    if unwrap(opts[:on_lookup]) not in [:relate, :ignore] do
      case opts[:on_lookup] do
        :relate_and_update when relationship.type == :many_to_many ->
          join(primary_action_name(relationship.through, :create), [])

        {:relate_and_update, action_name} when relationship.type == :many_to_many ->
          join(action_name, action_name)

        {:relate_and_update, action_name, _} when relationship.type == :many_to_many ->
          join(action_name, [])

        {:relate_and_update, action_name, _, keys} when relationship.type == :many_to_many ->
          join(action_name, keys)

        :relate_and_update when relationship.type in [:has_one, :has_many] ->
          destination(primary_action_name(relationship.destination, :update))

        :relate_and_update when relationship.type in [:belongs_to] ->
          source(primary_action_name(relationship.source, :update))

        {:relate_and_update, action_name} ->
          destination(action_name)

        {:relate_and_update, action_name, _} ->
          destination(action_name)

        _ ->
          nil
      end
    end
  end

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

    if unwrap(opts[:on_lookup]) not in [:ignore] do
      case opts[:on_lookup] do
        :relate ->
          destination(primary_action_name(relationship.destination, :read))

        {:relate, _} ->
          destination(primary_action_name(relationship.destination, :read))

        {:relate, _, read} ->
          destination(read)

        :relate_and_update when relationship.type in [:has_one, :has_many] ->
          destination(primary_action_name(relationship.destination, :read))

        :relate_and_update when relationship.type in [:belongs_to] ->
          source(primary_action_name(relationship.source, :read))

        {:relate_and_update, _} ->
          destination(:read)

        {:relate_and_update, _action_name, read} ->
          destination(read)

        {:relate_and_update, _action_name, read, _} ->
          destination(read)
      end
    end
  end

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

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

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

  defp join(nil, _), do: nil
  defp join(action_name, keys), do: {:join, action_name, 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 || unwrap(opts[:on_match]) == :no_match
  end

  def could_update?(opts) do
    unwrap(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? ||
        (unwrap(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
    primary_action = Ash.Resource.Info.primary_action(resource, type)

    if primary_action do
      primary_action.name
    else
      primary_action
    end
  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