lib/ash/resource/info.ex

defmodule Ash.Resource.Info do
  @moduledoc "Introspection for resources"

  alias Ash.Dsl.Extension

  @spec set_metadata(Ash.Resource.record(), map) :: Ash.Resource.record()
  def set_metadata(record, map) do
    %{record | __metadata__: Ash.Helpers.deep_merge_maps(record.__metadata__, map)}
  end

  @doc false
  def set_meta(%{__meta__: _} = struct, meta) do
    %{struct | __meta__: meta}
  end

  def set_meta(struct, _), do: struct

  @spec put_metadata(Ash.Resource.record(), atom, term) :: Ash.Resource.record()
  def put_metadata(record, key, term) do
    set_metadata(record, %{key => term})
  end

  def reverse_relationship(resource, path, acc \\ [])

  def reverse_relationship(_, [], acc),
    do: acc

  def reverse_relationship(resource, [name | rest], acc) do
    resource
    |> relationships()
    |> Enum.find(fn relationship ->
      relationship.name == name
    end)
    |> case do
      nil ->
        nil

      relationship ->
        relationship.destination
        |> relationships()
        |> Enum.find(fn candidate ->
          reverse_relationship?(relationship, candidate)
        end)
        |> case do
          nil ->
            nil

          destination_relationship ->
            reverse_relationship(relationship.destination, rest, [
              destination_relationship.name | acc
            ])
        end
    end
  end

  defp reverse_relationship?(rel, destination_rel) do
    rel.source == destination_rel.destination &&
      rel.destination == destination_rel.source &&
      rel.source_field == destination_rel.destination_field &&
      rel.destination_field == destination_rel.source_field &&
      Map.fetch(rel, :source_field_on_join_table) ==
        Map.fetch(destination_rel, :destination_field_on_join_table) &&
      Map.fetch(rel, :destination_field_on_join_table) ==
        Map.fetch(destination_rel, :source_field_on_join_table) &&
      is_nil(destination_rel.context) &&
      is_nil(rel.context)
  end

  @doc "Sets a list of loaded key or paths to a key back to their original unloaded stated"
  @spec unload_many(
          nil | list(Ash.Resource.record()) | Ash.Resource.record() | Ash.Page.page(),
          list(atom) | list(list(atom))
        ) ::
          nil | list(Ash.Resource.record()) | Ash.Resource.record() | Ash.Page.page()
  def unload_many(data, paths) do
    Enum.reduce(paths, data, &unload(&2, &1))
  end

  @doc "Sets a loaded key or path to a key back to its original unloaded stated"
  @spec unload(
          nil | list(Ash.Resource.record()) | Ash.Resource.record() | Ash.Page.page(),
          atom | list(atom)
        ) ::
          nil | list(Ash.Resource.record()) | Ash.Resource.record() | Ash.Page.page()
  def unload(nil, _), do: nil

  def unload(%struct{results: results} = page, path)
      when struct in [Ash.Page.Keyset, Ash.Page.Offset] do
    %{page | results: unload(results, path)}
  end

  def unload(records, path) when is_list(records) do
    Enum.map(records, &unload(&1, path))
  end

  def unload(record, [path]) do
    unload(record, path)
  end

  def unload(record, [key | rest]) do
    Map.update!(record, key, &unload(&1, rest))
  end

  def unload(%struct{} = record, key) when is_atom(key) do
    Map.put(record, key, Map.get(struct.__struct__(), key))
  end

  def unload(other, _), do: other

  @doc "Returns true if the load or path to load has been loaded"
  @spec loaded?(
          nil | list(Ash.Resource.record()) | Ash.Resource.record() | Ash.Page.page(),
          atom | list(atom)
        ) ::
          boolean
  def loaded?(nil, _), do: true

  def loaded?(%page{results: results}, path) when page in [Ash.Page.Keyset, Ash.Page.Offset] do
    loaded?(results, path)
  end

  def loaded?(records, path) when not is_list(path) do
    loaded?(records, [path])
  end

  def loaded?(records, path) when is_list(records) do
    Enum.all?(records, &loaded?(&1, path))
  end

  def loaded?(%Ash.NotLoaded{}, _), do: false

  def loaded?(_, []), do: true

  def loaded?(record, [key | rest]) do
    record
    |> Map.get(key)
    |> loaded?(rest)
  end

  @spec get_metadata(Ash.Resource.record(), atom | list(atom)) :: term
  def get_metadata(record, key_or_path) do
    get_in(record.__metadata__ || %{}, List.wrap(key_or_path))
  end

  @spec selected?(Ash.Resource.record(), atom) :: boolean
  def selected?(%resource{} = record, field) do
    case get_metadata(record, :selected) do
      nil ->
        attribute = Ash.Resource.Info.attribute(resource, field)

        attribute && (!attribute.private? || attribute.primary_key?)

      select ->
        if field in select do
          true
        else
          attribute = Ash.Resource.Info.attribute(resource, field)

          attribute && attribute.primary_key?
        end
    end
  end

  @spec interfaces(Ash.Resource.t()) :: [Ash.Resource.Interface.t()]
  def interfaces(resource) do
    Extension.get_entities(resource, [:code_interface])
  end

  @spec define_interface_in_resource?(Ash.Resource.t()) :: boolean
  def define_interface_in_resource?(resource) do
    !!Extension.get_opt(resource, [:code_interface], :define_for, false)
  end

  @spec define_interface_for(Ash.Resource.t()) :: atom | nil
  def define_interface_for(resource) do
    Extension.get_opt(resource, [:code_interface], :define_for, nil)
  end

  @spec extensions(Ash.Resource.t()) :: [module]
  def extensions(resource) do
    Extension.get_persisted(resource, :extensions, [])
  end

  @spec embedded?(Ash.Resource.t()) :: boolean
  def embedded?(resource) do
    Extension.get_persisted(resource, :embedded?, false)
  end

  @spec description(Ash.Resource.t()) :: String.t() | nil
  def description(resource) do
    Extension.get_opt(resource, [:resource], :description, "no description")
  end

  @spec base_filter(Ash.Resource.t()) :: term
  def base_filter(resource) do
    Extension.get_opt(resource, [:resource], :base_filter, nil)
  end

  @spec default_context(Ash.Resource.t()) :: term
  def default_context(resource) do
    Extension.get_opt(resource, [:resource], :default_context, nil)
  end

  @doc "A list of identities for the resource"
  @spec identities(Ash.Resource.t()) :: [Ash.Resource.Identity.t()]
  def identities(resource) do
    Extension.get_entities(resource, [:identities])
  end

  @doc "Get an identity by name from the resource"
  @spec identity(Ash.Resource.t(), atom) :: Ash.Resource.Identity.t() | nil
  def identity(resource, name) do
    resource
    |> identities()
    |> Enum.find(&(&1.name == name))
  end

  @doc "A list of authorizers to be used when accessing"
  @spec authorizers(Ash.Resource.t()) :: [module]
  def authorizers(resource) do
    Extension.get_persisted(resource, :authorizers, [])
  end

  @doc "A list of notifiers to be used when accessing"
  @spec notifiers(Ash.Resource.t()) :: [module]
  def notifiers(resource) do
    Extension.get_persisted(resource, :notifiers, [])
  end

  @spec validations(Ash.Resource.t(), :create | :update | :destroy) :: [
          Ash.Resource.Validation.t()
        ]
  def validations(resource, type) do
    resource
    |> validations()
    |> Enum.filter(&(type in &1.on))
  end

  @doc "A list of all validations for the resource"
  @spec validations(Ash.Resource.t()) :: [Ash.Resource.Validation.t()]
  def validations(resource) do
    Extension.get_entities(resource, [:validations])
  end

  @spec changes(Ash.Resource.t(), :create | :update | :destroy) ::
          list(
            Ash.Resource.Validation.t()
            | Ash.Resource.Change.t()
          )
  def changes(resource, type) do
    resource
    |> changes()
    |> Enum.filter(&(type in &1.on))
  end

  @doc "A list of all changes for the resource"
  @spec changes(Ash.Resource.t()) :: list(Ash.Resource.Validation.t() | Ash.Resource.Change.t())
  def changes(resource) do
    Extension.get_entities(resource, [:changes])
  end

  @spec preparations(Ash.Resource.t()) :: list(Ash.Resource.Preparation.t())
  def preparations(resource) do
    Extension.get_entities(resource, [:preparations])
  end

  @doc "Whether or not a given module is a resource module"
  @spec resource?(module) :: boolean
  def resource?(module) when is_atom(module) do
    Ash.Dsl.is?(module, Ash.Resource)
  end

  def resource?(_), do: false

  @doc "A list of field names corresponding to the primary key"
  @spec primary_key(Ash.Resource.t()) :: list(atom)
  def primary_key(resource) do
    Ash.Dsl.Extension.get_persisted(resource, :primary_key, [])
  end

  @doc "Returns all relationships of a resource"
  @spec relationships(Ash.Resource.t()) :: list(Ash.Resource.Relationships.relationship())
  def relationships(resource) do
    Extension.get_entities(resource, [:relationships])
  end

  @doc "Get a relationship by name or path"
  @spec relationship(Ash.Resource.t(), atom | String.t() | [atom | String.t()]) ::
          Ash.Resource.Relationships.relationship() | nil
  def relationship(resource, [name]) do
    relationship(resource, name)
  end

  def relationship(resource, [name | rest]) do
    case relationship(resource, name) do
      nil ->
        nil

      relationship ->
        relationship(relationship.destination, rest)
    end
  end

  def relationship(resource, relationship_name) when is_binary(relationship_name) do
    resource
    |> relationships()
    |> Enum.find(&(to_string(&1.name) == relationship_name))
  end

  def relationship(resource, relationship_name) do
    resource
    |> relationships()
    |> Enum.find(&(&1.name == relationship_name))
  end

  @doc "Returns all public relationships of a resource"
  @spec public_relationships(Ash.Resource.t()) :: list(Ash.Resource.Relationships.relationship())
  def public_relationships(resource) do
    resource
    |> relationships()
    |> Enum.reject(& &1.private?)
  end

  @doc "Get a public relationship by name or path"
  def public_relationship(resource, [name | rest]) do
    case public_relationship(resource, name) do
      nil ->
        nil

      relationship ->
        public_relationship(relationship.destination, rest)
    end
  end

  def public_relationship(resource, relationship_name) when is_binary(relationship_name) do
    resource
    |> relationships()
    |> Enum.find(&(to_string(&1.name) == relationship_name && !&1.private?))
  end

  def public_relationship(resource, relationship_name) do
    resource
    |> relationships()
    |> Enum.find(&(&1.name == relationship_name && !&1.private?))
  end

  @doc "Get the multitenancy strategy for a resource"
  @spec multitenancy_strategy(Ash.Resource.t()) :: :context | :attribute | nil
  def multitenancy_strategy(resource) do
    Ash.Dsl.Extension.get_opt(resource, [:multitenancy], :strategy, nil)
  end

  @spec multitenancy_attribute(Ash.Resource.t()) :: atom | nil
  def multitenancy_attribute(resource) do
    Ash.Dsl.Extension.get_opt(resource, [:multitenancy], :attribute, nil)
  end

  @spec multitenancy_parse_attribute(Ash.Resource.t()) :: {atom, atom, list(any)}
  def multitenancy_parse_attribute(resource) do
    Ash.Dsl.Extension.get_opt(
      resource,
      [:multitenancy],
      :parse_attribute,
      {__MODULE__, :_identity, []}
    )
  end

  @doc false
  def _identity(x), do: x

  @spec multitenancy_global?(Ash.Resource.t()) :: atom | nil
  def multitenancy_global?(resource) do
    Ash.Dsl.Extension.get_opt(resource, [:multitenancy], :global?, nil)
  end

  @spec multitenancy_source(Ash.Resource.t()) :: atom | nil
  def multitenancy_source(resource) do
    Ash.Dsl.Extension.get_opt(resource, [:multitenancy], :source, nil)
  end

  @spec multitenancy_template(Ash.Resource.t()) :: atom | nil
  def multitenancy_template(resource) do
    Ash.Dsl.Extension.get_opt(resource, [:multitenancy], :template, nil)
  end

  @doc "Returns all calculations of a resource"
  @spec calculations(Ash.Resource.t()) :: list(Ash.Resource.Calculation.t())
  def calculations(resource) do
    Extension.get_entities(resource, [:calculations])
  end

  @doc "Get a calculation by name"
  @spec calculation(Ash.Resource.t(), atom | String.t()) :: Ash.Resource.Calculation.t() | nil
  def calculation(resource, name) when is_binary(name) do
    resource
    |> calculations()
    |> Enum.find(&(to_string(&1.name) == name))
  end

  def calculation(resource, name) do
    resource
    |> calculations()
    |> Enum.find(&(&1.name == name))
  end

  @doc "Returns all public calculations of a resource"
  @spec public_calculations(Ash.Resource.t()) :: list(Ash.Resource.Calculation.t())
  def public_calculations(resource) do
    resource
    |> Extension.get_entities([:calculations])
    |> Enum.reject(& &1.private?)
  end

  @doc "Get a public calculation by name"
  @spec public_calculation(Ash.Resource.t(), atom | String.t()) ::
          Ash.Resource.Calculation.t() | nil
  def public_calculation(resource, name) when is_binary(name) do
    resource
    |> calculations()
    |> Enum.find(&(to_string(&1.name) == name && !&1.private?))
  end

  def public_calculation(resource, name) do
    resource
    |> calculations()
    |> Enum.find(&(&1.name == name && !&1.private?))
  end

  @doc "Returns all aggregates of a resource"
  @spec aggregates(Ash.Resource.t()) :: list(Ash.Resource.Aggregate.t())
  def aggregates(resource) do
    Extension.get_entities(resource, [:aggregates])
  end

  @doc "Get an aggregate by name"
  @spec aggregate(Ash.Resource.t(), atom | String.t()) :: Ash.Resource.Aggregate.t() | nil
  def aggregate(resource, name) when is_binary(name) do
    resource
    |> aggregates()
    |> Enum.find(&(to_string(&1.name) == name))
  end

  def aggregate(resource, name) do
    resource
    |> aggregates()
    |> Enum.find(&(&1.name == name))
  end

  @doc "Returns all public aggregates of a resource"
  @spec public_aggregates(Ash.Resource.t()) :: list(Ash.Resource.Aggregate.t())
  def public_aggregates(resource) do
    resource
    |> Extension.get_entities([:aggregates])
    |> Enum.reject(& &1.private?)
  end

  @doc "Get an aggregate by name"
  @spec public_aggregate(Ash.Resource.t(), atom | String.t()) ::
          Ash.Resource.Aggregate.t() | nil
  def public_aggregate(resource, name) when is_binary(name) do
    resource
    |> aggregates()
    |> Enum.find(&(to_string(&1.name) == name && !&1.private?))
  end

  def public_aggregate(resource, name) do
    resource
    |> aggregates()
    |> Enum.find(&(&1.name == name && !&1.private?))
  end

  @doc "Returns the primary action of the given type"
  @spec primary_action!(Ash.Resource.t(), Ash.Resource.Actions.action_type()) ::
          Ash.Resource.Actions.action() | no_return
  def primary_action!(resource, type) do
    case primary_action(resource, type) do
      nil -> raise "Required primary #{type} action for #{inspect(resource)}."
      action -> action
    end
  end

  @doc "Returns the primary action of a given type"
  @spec primary_action(Ash.Resource.t(), Ash.Resource.Actions.action_type()) ::
          Ash.Resource.Actions.action() | nil
  def primary_action(resource, type) do
    resource
    |> actions()
    |> Enum.find(&(&1.type == type && &1.primary?))
  end

  @doc "Returns the configured default actions"
  @spec default_actions(Ash.Resource.t()) :: list(:create | :read | :update | :destroy)
  def default_actions(resource) do
    default =
      if embedded?(resource) do
        [:create, :read, :update, :destroy]
        |> Enum.reject(&Ash.Resource.Info.action(resource, &1))
      else
        []
      end

    Extension.get_opt(
      resource,
      [:actions],
      :defaults,
      default
    )
  end

  @doc "Returns all actions of a resource"
  @spec actions(Ash.Resource.t()) :: [Ash.Resource.Actions.action()]
  def actions(resource) do
    Extension.get_entities(resource, [:actions])
  end

  @doc "Returns the action with the matching name and type on the resource"
  @spec action(Ash.Resource.t(), atom(), Ash.Resource.Actions.action_type() | nil) ::
          Ash.Resource.Actions.action() | nil
  def action(resource, name, type \\ nil) do
    # We used to need type, but we don't anymore since action names are unique
    if type do
      resource
      |> actions()
      |> Enum.find(&(&1.name == name))
      |> case do
        nil ->
          nil

        %{type: ^type} = action ->
          action

        %{type: found_type} ->
          raise ArgumentError, """
          Found an action of type #{found_type} while looking for an action of type #{type}

          Perhaps you passed a changeset with the incorrect action type into your Api?
          """
      end
    else
      resource
      |> actions()
      |> Enum.find(&(&1.name == name))
    end
  end

  @doc "Returns all attributes of a resource"
  @spec attributes(Ash.Resource.t()) :: [Ash.Resource.Attribute.t()]
  def attributes(resource) do
    Extension.get_entities(resource, [:attributes])
  end

  @doc "Get an attribute name from the resource"
  @spec attribute(Ash.Resource.t(), String.t() | atom) :: Ash.Resource.Attribute.t() | nil
  def attribute(resource, name) when is_binary(name) do
    resource
    |> attributes()
    |> Enum.find(&(to_string(&1.name) == name))
  end

  def attribute(resource, name) do
    resource
    |> attributes()
    |> Enum.find(&(&1.name == name))
  end

  @doc "Returns all public attributes of a resource"
  @spec public_attributes(Ash.Resource.t()) :: [Ash.Resource.Attribute.t()]
  def public_attributes(resource) do
    resource
    |> attributes()
    |> Enum.reject(& &1.private?)
  end

  @doc "Get a public attribute name from the resource"
  @spec public_attribute(Ash.Resource.t(), String.t() | atom) :: Ash.Resource.Attribute.t() | nil
  def public_attribute(resource, name) when is_binary(name) do
    resource
    |> attributes()
    |> Enum.find(&(to_string(&1.name) == name && !&1.private?))
  end

  def public_attribute(resource, name) do
    resource
    |> attributes()
    |> Enum.find(&(&1.name == name && !&1.private?))
  end

  @spec related(Ash.Resource.t(), atom() | String.t() | [atom() | String.t()]) ::
          Ash.Resource.t() | nil
  def related(resource, relationship) when not is_list(relationship) do
    related(resource, [relationship])
  end

  def related(resource, []), do: resource

  def related(resource, [path | rest]) do
    case relationship(resource, path) do
      %{destination: destination} -> related(destination, rest)
      nil -> nil
    end
  end

  @doc "Get a field from a resource by name"
  @spec field(Ash.Resource.t(), String.t() | atom) ::
          Ash.Resource.Attribute.t()
          | Ash.Resource.Aggregate.t()
          | Ash.Resource.Calculation.t()
          | Ash.Resource.Relationships.relationship()
          | nil
  def field(resource, name),
    do:
      attribute(resource, name) ||
        aggregate(resource, name) ||
        calculation(resource, name) ||
        relationship(resource, name)

  @doc "Get a public field from a resource by name"
  @spec public_field(Ash.Resource.t(), String.t() | atom) ::
          Ash.Resource.Attribute.t()
          | Ash.Resource.Aggregate.t()
          | Ash.Resource.Calculation.t()
          | Ash.Resource.Relationships.relationship()
          | nil
  def public_field(resource, name),
    do:
      public_attribute(resource, name) ||
        public_aggregate(resource, name) ||
        public_calculation(resource, name) ||
        public_relationship(resource, name)

  @doc "Determine if a field is sortable by name"
  @spec sortable?(Ash.Resource.t(), String.t() | atom,
          pagination_type: Ash.Page.type(),
          include_private?: boolean()
        ) ::
          boolean()
  def sortable?(resource, name, opts \\ []) do
    pagination_type = Keyword.get(opts, :pagination_type, :offset)
    include_private? = Keyword.get(opts, :include_private?, true)

    field = if include_private?, do: field(resource, name), else: public_field(resource, name)

    case field do
      nil ->
        false

      %{type: {:array, _}} ->
        false

      %{type: Ash.Type.Map} ->
        false

      %Ash.Resource.Relationships.BelongsTo{} ->
        false

      %Ash.Resource.Relationships.HasOne{} ->
        false

      %Ash.Resource.Relationships.HasMany{} ->
        false

      %Ash.Resource.Relationships.ManyToMany{} ->
        false

      %Ash.Resource.Calculation{calculation: {module, _}} ->
        Code.ensure_compiled(module)
        :erlang.function_exported(module, :expression, 2) && pagination_type == :offset

      %Ash.Resource.Calculation{} ->
        false

      %Ash.Resource.Aggregate{kind: :first, relationship_path: relationship_path} = aggregate ->
        related = related(resource, relationship_path)
        sortable?(related, aggregate.field) && pagination_type == :offset

      %Ash.Resource.Aggregate{} ->
        pagination_type == :offset

      _ ->
        true
    end
  end
end