lib/ash/query/calculation.ex

defmodule Ash.Query.Calculation do
  @moduledoc "Represents a calculated attribute requested on a query"

  defstruct [
    :name,
    :module,
    :opts,
    :load,
    :type,
    :constraints,
    :calc_name,
    context: %{},
    required_loads: [],
    select: [],
    filterable?: true,
    async?: false,
    sortable?: true,
    sensitive?: false
  ]

  @type t :: %__MODULE__{}

  @opt_schema [
    arguments: [
      type: :map,
      doc: "Arguments to pass to the calculation",
      default: %{}
    ],
    async?: [
      type: :boolean,
      default: false,
      doc: "Whether or not this calculation should be run asynchronously"
    ],
    filterable?: [
      type: :boolean,
      doc: "Whether or not this calculation can be filtered on",
      default: true
    ],
    sortable?: [
      type: :boolean,
      doc: "Whether or not this calculation can be sorted on",
      default: true
    ],
    sensitive?: [
      type: :boolean,
      doc: "Whether or not references to this calculation will be considered sensitive",
      default: false
    ],
    load: [
      type: :any,
      doc: "Loads that are required for the calculation."
    ],
    calc_name: [
      type: :any,
      hide: true,
      doc: "A resource calculation this calculation maps to."
    ],
    actor: [
      type: :any,
      doc: "The actor performing the calculation."
    ],
    tenant: [
      type: :any,
      doc: "The tenant performing the calculation."
    ],
    authorize?: [
      type: :boolean,
      doc: "Whether or not authorization is being performed"
    ],
    tracer: [
      type: :any,
      doc: "The tracer or tracers used in the calculation."
    ],
    source_context: [
      type: :map,
      doc: "Context from the source query or changeset.",
      default: %{}
    ]
  ]

  opt_schema = @opt_schema

  defmodule Opts do
    @moduledoc false
    use Spark.Options.Validator, schema: opt_schema
  end

  @doc """
  Creates a new query calculation.

  ## Options

  #{Spark.Options.docs(@opt_schema)}
  """
  def new(
        name,
        module,
        calc_opts,
        type,
        constraints,
        opts \\ []
      ) do
    with {:ok, opts} <- Opts.validate(opts),
         {:ok, calc_opts} <- module.init(calc_opts) do
      authorize? =
        if :authorize? in opts.__set__ do
          opts.authorize?
        else
          opts.source_context[:private][:authorize?]
        end

      context = %Ash.Resource.Calculation.Context{
        arguments: opts.arguments,
        type: type,
        constraints: constraints,
        authorize?: authorize?,
        actor: opts.actor || opts.source_context[:private][:actor],
        tenant: opts.tenant || opts.source_context[:private][:tenant],
        tracer: opts.tracer || opts.source_context[:private][:tracer],
        source_context: opts.source_context
      }

      calc_name =
        if :calc_name in opts.__set__ do
          opts.calc_name
        end

      {:ok,
       %__MODULE__{
         name: name,
         module: module,
         type: type,
         opts: calc_opts,
         calc_name: calc_name,
         constraints: constraints,
         context: context,
         async?: opts.async?,
         required_loads: opts.load,
         filterable?: opts.filterable?,
         sortable?: opts.sortable?,
         sensitive?: opts.sensitive?
       }}
    end
  end

  @from_resource_schema [
    args: [
      type: :map,
      doc: "Arguments to pass to the calculation",
      default: %{}
    ],
    source_context: [
      type: :map,
      doc: "Context from the source query or changeset.",
      default: %{}
    ]
  ]

  from_resource_schema = @from_resource_schema

  defmodule FromResourceOpts do
    @moduledoc false
    use Spark.Options.Validator, schema: from_resource_schema
  end

  @doc """
  Creates a new query calculation from a resource calculation, raising any errors.

  See `from_resource_calculation/3` for more.
  """
  def from_resource_calculation!(
        resource,
        name,
        opts \\ []
      ) do
    case from_resource_calculation(resource, name, opts) do
      {:ok, calculation} ->
        calculation

      {:error, error} ->
        raise Ash.Error.to_ash_error(error)
    end
  end

  @doc """
  Creates a new query calculation from a resource calculation.

  ## Options

  #{Spark.Options.docs(@from_resource_schema)}
  """
  def from_resource_calculation(
        resource,
        name,
        opts \\ []
      ) do
    {name, resource_calculation} =
      case name do
        %Ash.Resource.Calculation{} = calc ->
          {calc.name, calc}

        name ->
          {name,
           Ash.Resource.Info.calculation(resource, name) ||
             raise(
               ArgumentError,
               "No calculation called #{inspect(name)} found on #{inspect(resource)}"
             )}
      end

    %{calculation: {module, calc_opts}} = resource_calculation

    with {:ok, opts} <- FromResourceOpts.validate(opts),
         {:ok, args} <-
           Ash.Query.validate_calculation_arguments(resource_calculation, opts.args) do
      new(
        name,
        module,
        calc_opts,
        resource_calculation.type,
        resource_calculation.constraints,
        arguments: args,
        async?: resource_calculation.async?,
        filterable?: resource_calculation.filterable?,
        sortable?: resource_calculation.sortable?,
        sensitive?: resource_calculation.sensitive?,
        load: resource_calculation.load,
        source_context: opts.source_context,
        calc_name: resource_calculation.name
      )
    end
  end

  defimpl Inspect do
    import Inspect.Algebra

    def inspect(%{context: context, calc_name: calc_name}, inspect_opts)
        when not is_nil(calc_name) do
      calc_name =
        if is_atom(calc_name) or is_binary(calc_name) do
          to_string(calc_name)
        else
          to_doc(calc_name, inspect_opts)
        end

      if context.arguments == %{} do
        calc_name
      else
        concat([
          calc_name,
          "(",
          to_doc(Map.to_list(context.arguments), inspect_opts),
          ")"
        ])
      end
    end

    def inspect(%{module: module, opts: calculation_opts, context: context}, _opts) do
      if context.arguments == %{} do
        module.describe(calculation_opts)
      else
        concat([module.describe(calculation_opts), " - ", inspect(context.arguments)])
      end
    end
  end
end