lib/ash/resource/actions/read.ex

defmodule Ash.Resource.Actions.Read do
  @moduledoc "Represents a read action on a resource."

  defstruct arguments: [],
            description: nil,
            filter: nil,
            filters: [],
            get_by: nil,
            get?: nil,
            manual: nil,
            metadata: [],
            skip_unknown_inputs: [],
            modify_query: nil,
            multitenancy: nil,
            name: nil,
            pagination: nil,
            preparations: [],
            primary?: nil,
            touches_resources: [],
            timeout: nil,
            transaction?: false,
            type: :read

  @type t :: %__MODULE__{
          arguments: [Ash.Resource.Actions.Argument.t()],
          description: String.t() | nil,
          filter: any,
          get_by: nil | atom | [atom],
          get?: nil | boolean,
          filters: [any],
          manual: atom | {atom, Keyword.t()} | nil,
          metadata: [Ash.Resource.Actions.Metadata.t()],
          skip_unknown_inputs: list(atom | String.t()),
          modify_query: nil | mfa,
          multitenancy: atom,
          name: atom,
          pagination: any,
          primary?: boolean,
          touches_resources: [atom],
          timeout: pos_integer() | nil,
          transaction?: boolean,
          type: :read
        }

  import Ash.Resource.Actions.SharedOptions

  @global_opts shared_options()

  @opt_schema Spark.Options.merge(
                [
                  manual: [
                    type:
                      {:spark_function_behaviour, Ash.Resource.ManualRead,
                       {Ash.Resource.ManualRead.Function, 3}},
                    doc: """
                    Delegates running of the query to the provided module. Accepts a module or module and opts, or a function that takes the ash query, the data layer query, and context. See the [manual actions guide](/documentation/topics/manual-actions.md) for more.
                    """
                  ],
                  get?: [
                    type: :boolean,
                    default: false,
                    doc: """
                    Expresses that this action innately only returns a single result. Used by extensions to validate and/or modify behavior. Causes code interfaces to return a single value instead of a list. See the [code interface guide](/documentation/topics/resources/code-interfaces.md) for more.
                    """
                  ],
                  modify_query: [
                    type: {:or, [:mfa, {:fun, 2}]},
                    doc: """
                    Allows direct manipulation of the data layer query via an MFA. The ash query and the data layer query will be provided as additional arguments. The result must be `{:ok, new_data_layer_query} | {:error, error}`.
                    """
                  ],
                  get_by: [
                    type: {:wrap_list, :atom},
                    default: nil,
                    doc: """
                    A helper to automatically generate a "get by X" action. Sets `get?` to true, add args for each of the specified fields, and adds a filter for each of the arguments.
                    """
                  ],
                  timeout: [
                    type: :pos_integer,
                    doc: """
                    The maximum amount of time, in milliseconds, that the action is allowed to run for. Ignored if the data layer doesn't support transactions *and* async is disabled.
                    """
                  ],
                  multitenancy: [
                    type: {:in, [:enforce, :allow_global, :bypass, :bypass_all]},
                    default: :enforce,
                    doc: """
                    This setting defines how this action handles multitenancy. `:enforce` requires a tenant to be set (the default behavior), `:allow_global` allows using this action both with and without a tenant, `:bypass` completely ignores the tenant even if it's set, `:bypass_all` like `:bypass` but also bypasses the tenancy requirement for the nested resources. This is useful to change the behaviour of selected read action without the need of marking the whole resource with `global? true`.
                    """
                  ]
                ],
                @global_opts,
                "Action Options"
              )

  @pagination_schema [
    keyset?: [
      type: :boolean,
      doc: "Whether or not keyset based pagination is supported",
      default: false
    ],
    offset?: [
      type: :boolean,
      doc: "Whether or not offset based pagination is supported",
      default: false
    ],
    default_limit: [
      type: :pos_integer,
      doc: "The default page size to apply, if one is not supplied"
    ],
    # Change this default in 4.0
    countable: [
      type: {:in, [true, false, :by_default]},
      doc:
        "Whether not a returned page will have a full count of all records. Use `:by_default` to do it automatically.",
      default: true
    ],
    max_page_size: [
      type: :pos_integer,
      doc: "The maximum amount of records that can be requested in a single page",
      default: 250
    ],
    stable_sort: [
      type: :any,
      doc:
        "A stable sort statement to add to a query (after any existing sorts). Only added if the sort does not already contain a stable sort (sorting on fields that uniquely identify a record). Defaults to the primary key."
    ],
    required?: [
      type: :boolean,
      doc:
        "Whether or not pagination can be disabled (by passing `page: false` to `Ash.Api.read!/2`, or by having `required?: false, default_limit: nil` set). Only relevant if some pagination configuration is supplied.",
      default: true
    ]
  ]

  defmodule Pagination do
    @moduledoc "Represents the pagination configuration of a read action"
    defstruct [
      :default_limit,
      :max_page_size,
      countable: false,
      stable_sort: nil,
      required?: false,
      keyset?: false,
      offset?: false
    ]

    @type t :: %__MODULE__{}

    def transform(pagination) do
      if pagination.keyset? or pagination.offset? do
        {:ok, pagination}
      else
        {:error, "Must enable `keyset?` or `offset?`"}
      end
    end
  end

  def transform(read) do
    {:ok,
     read
     |> transform_pagination()
     |> concat_filters()}
  end

  def concat_filters(%{filters: [filter]} = read) do
    %{read | filter: filter.filter}
  end

  def concat_filters(%{filters: [first | rest]} = read) do
    filter =
      Enum.reduce(rest, first.filter, fn filter, acc ->
        Ash.Query.BooleanExpression.new(:and, filter.filter, acc)
      end)

    %{read | filter: filter}
  end

  @doc false
  def concat_filters(read) do
    read
  end

  defp transform_pagination(read) do
    if read.pagination do
      if is_list(read.pagination) do
        %{read | pagination: List.last(read.pagination) || false}
      else
        %{read | pagination: read.pagination}
      end
    else
      %{read | pagination: false}
    end
  end

  @doc false
  def opt_schema, do: @opt_schema
  def pagination_schema, do: @pagination_schema
end