lib/ash/resource/interface.ex

defmodule Ash.Resource.Interface do
  @moduledoc """
  Represents a function in a resource's code interface

  See the functions defined in this module for specifications of the options
  that each type of code interface function supports in its options list.
  """
  defstruct [
    :name,
    :action,
    :get?,
    :get_by,
    :get_by_identity,
    :not_found_error?,
    args: [],
    custom_inputs: [],
    exclude_inputs: [],
    default_options: [],
    require_reference?: true
  ]

  @type t :: %__MODULE__{}

  defp set_get?(definition) do
    if definition.get_by || definition.get_by_identity do
      %{definition | get?: true}
    else
      definition
    end
  end

  defp set_require_reference?(%{get?: true} = definition) do
    %{definition | require_reference?: false}
  end

  defp set_require_reference?(definition) do
    definition
  end

  defmodule CanOpts do
    @moduledoc false
    use Spark.Options.Validator, schema: Keyword.delete(Ash.can_opts(), :actor)
  end

  defmodule CanQuestionMarkOpts do
    @moduledoc false
    use Spark.Options.Validator, schema: Keyword.delete(Ash.can_question_mark_opts(), :actor)
  end

  defmodule CalculateOpts do
    @moduledoc false

    use Spark.Options.Validator,
      schema: Keyword.drop(Ash.calculate_opts(), [:domain, :refs, :args, :record])
  end

  defmodule CreateOpts do
    @moduledoc false

    use Spark.Options.Validator,
      schema:
        Keyword.merge(Ash.create_opts(),
          changeset: [
            type: :any,
            doc: "A changeset to seed the action with."
          ],
          bulk_options: [
            type: :keyword_list,
            doc:
              "Options passed to `Ash.bulk_create`, if a list or stream of inputs is provided.",
            keys: Keyword.drop(Ash.bulk_create_opts(), Keyword.keys(Ash.create_opts()))
          ],
          private_arguments: [
            type: :map,
            doc: "Private argument values to set before validations and changes.",
            default: %{}
          ]
        )
        |> Keyword.drop([:domain])
  end

  defmodule UpdateOpts do
    @moduledoc false
    use Spark.Options.Validator,
      schema:
        Ash.update_opts()
        |> Keyword.merge(
          bulk_options: [
            type: :keyword_list,
            doc:
              "Options passed to `Ash.bulk_update`, if a query, list, or stream of inputs is provided.",
            keys:
              Keyword.drop(Ash.bulk_update_opts(), Keyword.keys(Ash.update_opts()) ++ [:resource])
          ],
          private_arguments: [
            type: :map,
            doc: "Private argument values to set before validations and changes.",
            default: %{}
          ]
        )
        |> Keyword.drop([:domain])
  end

  defmodule DestroyOpts do
    @moduledoc false
    use Spark.Options.Validator,
      schema:
        Ash.destroy_opts()
        |> Keyword.merge(
          bulk_options: [
            type: :keyword_list,
            doc:
              "Options passed to `Ash.bulk_destroy`, if a query, list, or stream of inputs is provided.",
            keys:
              Keyword.drop(
                Ash.bulk_destroy_opts(),
                Keyword.keys(Ash.destroy_opts()) ++ [:resource]
              )
          ],
          private_arguments: [
            type: :map,
            doc: "Private argument values to set before validations and changes.",
            default: %{}
          ]
        )
        |> Keyword.drop([:domain])
  end

  defmodule GetOpts do
    @moduledoc false
    use Spark.Options.Validator,
      schema:
        Ash.read_opts()
        |> Keyword.merge(
          query: [
            type: {:or, [{:behaviour, Ash.Resource}, {:struct, Ash.Query}, :keyword_list]},
            doc: "A query to seed the action with."
          ],
          not_found_error?: [
            type: :boolean,
            doc:
              "Whether or not to return or raise a `NotFound` error or to return `nil` when a get? action/interface is called."
          ]
        )
        |> Keyword.drop([:domain])
  end

  defmodule ReadOpts do
    @moduledoc false
    use Spark.Options.Validator,
      schema:
        Ash.read_opts()
        |> Keyword.merge(
          query: [
            type: {:or, [{:behaviour, Ash.Resource}, {:struct, Ash.Query}, :keyword_list]},
            doc: "A query to seed the action with."
          ],
          not_found_error?: [
            type: :boolean,
            doc:
              "Whether or not to return or raise a `NotFound` error or to return `nil` when a get? action/interface is called."
          ]
        )
        |> Keyword.merge(
          stream?: [
            type: :boolean,
            default: false,
            doc: "If true, a stream of the results will be returned"
          ],
          stream_options: [
            type: :keyword_list,
            doc: "Options passed to `Ash.stream!`, if `stream?: true` is given",
            keys: Keyword.drop(Ash.stream_opts(), Keyword.keys(Ash.read_opts()))
          ]
        )
        |> Keyword.drop([:domain])
  end

  defmodule ActionOpts do
    @moduledoc false
    use Spark.Options.Validator,
      schema:
        Ash.run_action_opts()
        |> Keyword.merge(
          private_arguments: [
            type: :map,
            doc: "Private argument values to set before validations and changes.",
            default: %{}
          ]
        )
        |> Keyword.drop([:domain])
  end

  @doc """
  Options supported by code interfaces for create actions

  ## Options

  #{CreateOpts.docs()}
  """
  def create_opts do
    CreateOpts
  end

  @doc """
  Options supported by code interfaces for read actions with `get?` or `get_by` set.

  ## Options

  #{ReadOpts.docs()}
  """
  def get_opts do
    ReadOpts
  end

  @doc """
  Options supported by code interfaces for read actions

  ## Options

  #{ReadOpts.docs()}
  """
  def read_opts do
    ReadOpts
  end

  @doc """
  Options supported by code interfaces for update actions

  ## Options

  #{UpdateOpts.docs()}
  """
  def update_opts do
    UpdateOpts
  end

  @doc """
  Options supported by code interfaces for destroy actions

  ## Options

  #{DestroyOpts.docs()}
  """
  def destroy_opts do
    DestroyOpts
  end

  @doc """
  Options supported by code interfaces for generic actions

  ## Options

  #{ActionOpts.docs()}
  """
  def generic_action_opts do
    ActionOpts
  end

  @doc """
  Options supported by code interfaces for calculations

  ## Options

  #{CalculateOpts.docs()}
  """
  def calculate_opts do
    CalculateOpts
  end

  @doc false
  def interface_options(:calculate, _) do
    calculate_opts()
  end

  def interface_options(:create, _) do
    create_opts()
  end

  def interface_options(:update, _) do
    update_opts()
  end

  def interface_options(:destroy, _) do
    destroy_opts()
  end

  def interface_options(:read, interface) do
    if interface.get? do
      get_opts()
    else
      read_opts()
    end
  end

  def interface_options(:action, _) do
    generic_action_opts()
  end

  @schema [
    name: [
      type: :atom,
      doc: "The name of the function that will be defined",
      required: true
    ],
    action: [
      type: :atom,
      doc:
        "The name of the action that will be called. Defaults to the same name as the function."
    ],
    args: [
      type: {:list, {:or, [:atom, {:tagged_tuple, :optional, :atom}]}},
      doc: """
      Map specific arguments to named inputs. Can provide any argument/attributes that the action allows.
      """
    ],
    not_found_error?: [
      type: :boolean,
      default: true,
      doc:
        "If the action or interface is configured with `get?: true`, this determines whether or not an error is raised or `nil` is returned."
    ],
    require_reference?: [
      type: :boolean,
      default: true,
      doc:
        "For update and destroy actions, require a resource or identifier to be passed in as the first argument. Not relevant for other action types."
    ],
    exclude_inputs: [
      type: {:list, :atom},
      default: [],
      doc: "A list of action inputs to not accept in the defined interface"
    ],
    get?: [
      type: :boolean,
      default: false,
      doc: """
      Expects to only receive a single result from a read action or a bulk update/destroy, and returns a single result instead of a list. Sets `require_reference?` to false automatically.
      """
    ],
    get_by: [
      type: {:wrap_list, :atom},
      doc: """
      Takes a list of fields and adds those fields as arguments, which will then be used to filter. Sets `get?` to true and `require_reference?` to false automatically. Adds filters for read, update and destroy actions, replacing the `record` first argument.
      """
    ],
    get_by_identity: [
      type: :atom,
      doc: """
      Takes an identity, gets its field list, and performs the same logic as `get_by` with those fields. Adds filters for read, update and destroy actions, replacing the `record` first argument.
      """
    ],
    default_options: [
      type: {:or, [:keyword_list, {:fun, 0}]},
      default: [],
      doc:
        "Default options to be merged with client-provided options. These can override domain or action defaults. `:load`, `:bulk_options`, and `:page` options will be deep merged. Can be a keyword list or a zero-arity function that returns a keyword list."
    ]
  ]

  @doc false
  def schema, do: @schema

  @doc false
  def transform(definition) do
    {:ok,
     definition
     |> set_get?()
     |> set_require_reference?()}
  end
end