# SPDX-FileCopyrightText: 2019 ash contributors # # SPDX-License-Identifier: MIT defmodule Ash.Resource.Calculation do @moduledoc """ The behaviour for defining a module calculation, and the struct for storing a defined calculation. """ defstruct allow_nil?: true, arguments: [], calculation: nil, constraints: [], description: nil, field?: true, filterable?: true, sortable?: true, sensitive?: false, multitenancy: nil, load: [], name: nil, public?: false, async?: false, type: nil, __spark_metadata__: nil @schema [ name: [ type: :atom, required: true, doc: "The field name to use for the calculation value" ], type: [ type: :any, doc: "The type of the calculation. See `Ash.Type` for more.", required: true ], async?: [ type: :boolean, default: false ], constraints: [ type: :keyword_list, default: [], doc: "Constraints to provide to the type. See `Ash.Type` for more." ], calculation: [ type: Ash.OptionsHelpers.calculation_type(), required: true, doc: """ The `module`, `{module, opts}` or `expr(...)` to use for the calculation. Also accepts a function that takes *a list of records* and the context, and produces a result for each record. """ ], description: [ type: :string, doc: "An optional description for the calculation" ], public?: [ type: :boolean, default: false, doc: """ Whether or not the calculation will appear in public interfaces. """ ], sensitive?: [ type: :boolean, default: false, doc: """ Whether or not references to the calculation will be considered sensitive. """ ], load: [ type: :any, default: [], doc: "A load statement to be applied if the calculation is used. Only works with module-based or function-based calculations, not expression calculations." ], allow_nil?: [ type: :boolean, default: true, doc: "Whether or not the calculation can return nil." ], filterable?: [ type: {:or, [:boolean, {:in, [:simple_equality]}]}, default: true, doc: "Whether or not the calculation should be usable in filters." ], sortable?: [ type: :boolean, default: true, doc: """ Whether or not the calculation can be referenced in sorts. """ ], field?: [ type: :boolean, default: true, doc: """ Whether or not the calculation should create a field on the resource struct. When `false`, the calculation's value will always be stored in the `calculations` map on the record, and will not add a key to the resource struct. The calculation can still be loaded normally. """ ], multitenancy: [ type: {:in, [:enforce, :allow_global, :bypass, :bypass_all]}, doc: """ Configures multitenancy behavior for the calculation. `:enforce` requires a tenant to be set (the default behavior), `:allow_global` allows using this calculation 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 nested resources. """ ] ] defmodule Context do @moduledoc """ The context and arguments of a calculation """ defstruct [ :actor, :tenant, :authorize?, :tracer, :domain, :resource, :type, :constraints, :arguments, source_context: %{} ] @type t :: %__MODULE__{ actor: term | nil, tenant: term(), authorize?: boolean, tracer: module | nil, source_context: map(), resource: module(), type: Ash.Type.t(), constraints: Keyword.t(), domain: module(), arguments: map() } end @type t :: %__MODULE__{ allow_nil?: boolean, arguments: [__MODULE__.Argument.t()], calculation: module | {module, keyword}, constraints: keyword, async?: boolean, description: nil | String.t(), field?: boolean, filterable?: boolean, load: keyword, sortable?: boolean, sensitive?: boolean, multitenancy: nil | :enforce | :allow_global | :bypass | :bypass_all, name: atom(), public?: boolean, type: nil | Ash.Type.t(), __spark_metadata__: Spark.Dsl.Entity.spark_meta() } @type ref :: {module(), Keyword.t()} | module() defmacro __using__(_) do quote do @behaviour Ash.Resource.Calculation @before_compile Ash.Resource.Calculation import Ash.Expr def init(opts), do: {:ok, opts} def describe(opts), do: "##{inspect(__MODULE__)}<#{inspect(opts)}>" def load(_query, _opts, _context), do: [] defoverridable init: 1, describe: 1, load: 3 end end defmacro __before_compile__(_) do quote do if Module.defines?(__MODULE__, {:expression, 2}) do def has_expression?, do: true else def has_expression?, do: false end if Module.defines?(__MODULE__, {:calculate, 3}) do def has_calculate?, do: true else def has_calculate?, do: false end def strict_loads?, do: true defoverridable strict_loads?: 0 end end @type opts :: Keyword.t() @callback init(opts :: opts) :: {:ok, opts} | {:error, term} @callback describe(opts :: opts) :: String.t() @callback calculate(records :: [Ash.Resource.Record.t()], opts :: opts, context :: Context.t()) :: {:ok, [term]} | [term] | {:error, term} | :unknown @callback expression(opts :: opts, context :: Context.t()) :: any @doc """ A load statement to be applied when the calculation is used. Only works with module-based or function-based calculations, not expression calculations. """ @callback load(query :: Ash.Query.t(), opts :: opts, context :: Context.t()) :: atom | [atom] | Keyword.t() @callback strict_loads?() :: boolean() @callback has_expression?() :: boolean() @optional_callbacks expression: 2, calculate: 3 def schema, do: @schema @doc false @spec init(module(), opts) :: {:ok, opts} | {:error, term} def init(module, opts) do Ash.BehaviourHelpers.call_and_validate_return( module, :init, [opts], [{:ok, :_}, {:error, :_}], behaviour: __MODULE__, callback_name: "init/1" ) end @doc false @spec describe(module(), opts) :: String.t() def describe(module, opts) do result = apply(module, :describe, [opts]) if is_binary(result) do result else raise Ash.Error.Framework.InvalidReturnType, message: """ Invalid value returned from #{inspect(module)}.describe/1. The callback #{inspect(__MODULE__)}.describe/1 expects a String.t(). """ end end @doc false @spec calculate(module(), [Ash.Resource.Record.t()], opts, Context.t()) :: {:ok, [term()]} | [term()] | {:error, term()} | :unknown def calculate(module, records, opts, context) do result = apply(module, :calculate, [records, opts, context]) if match?({:ok, _}, result) or is_list(result) or match?({:error, _}, result) or result == :unknown do result else raise Ash.Error.Framework.InvalidReturnType, message: """ Invalid value returned from #{inspect(module)}.calculate/3. The callback #{inspect(__MODULE__)}.calculate/3 expects {:ok, [term()]}, [term()], {:error, term()}, or :unknown. """ end end @doc false @spec expression(module(), opts, Context.t()) :: any() def expression(module, opts, context) do apply(module, :expression, [opts, context]) end @doc false @spec load(module(), Ash.Query.t(), opts, Context.t() | map()) :: atom() | [atom()] | Keyword.t() def load(module, query, opts, context) do result = apply(module, :load, [query, opts, context]) # Accept atom or list (implementations may return list of atoms, keyword list, or list of refs) if is_atom(result) or is_list(result) do result else raise Ash.Error.Framework.InvalidReturnType, message: """ Invalid value returned from #{inspect(module)}.load/3. The callback #{inspect(__MODULE__)}.load/3 expects an atom or a list. """ end end @doc false @spec has_expression?(module()) :: boolean() def has_expression?(module) do Ash.BehaviourHelpers.call_and_validate_return( module, :has_expression?, [], [true, false], behaviour: __MODULE__, callback_name: "has_expression?/0" ) end @doc false @spec has_calculate?(module()) :: boolean() def has_calculate?(module) do Ash.BehaviourHelpers.call_and_validate_return( module, :has_calculate?, [], [true, false], behaviour: __MODULE__, callback_name: "has_calculate?/0" ) end @doc false @spec strict_loads?(module()) :: boolean() def strict_loads?(module) do Ash.BehaviourHelpers.call_and_validate_return( module, :strict_loads?, [], [true, false], behaviour: __MODULE__, callback_name: "strict_loads?/0" ) end @doc false def expr_calc(expr) when is_function(expr) do {:error, "Inline function calculations expect a function with arity 2. Got #{Function.info(expr)[:arity]}"} end def expr_calc(expr) do {:ok, {Ash.Resource.Calculation.Expression, expr: expr}} end end