defmodule Ash.CodeInterface do
@moduledoc """
Used to define the functions of a code interface for a resource.
"""
@doc false
def require_action(resource, interface) do
action = Ash.Resource.Info.action(resource, interface.action || interface.name)
if !action do
raise Spark.Error.DslError,
module: resource,
message:
"The interface of #{inspect(resource)} refers to a non-existent action #{interface.action || interface.name}",
path: [:interfaces, :interface, interface.name]
end
action
end
@doc false
def default_value(resource, action, key) do
{field_type, field} =
case Enum.find(action.arguments, fn argument ->
argument.name == key
end) do
nil ->
{:attribute, Ash.Resource.Info.attribute(resource, key)}
argument ->
{:argument, argument}
end
cond do
field.allow_nil? && !(field.name in Map.get(action, :require_attributes, [])) ->
:ok
field.name in Map.get(action, :allow_nil_input, []) ->
:ok
!(field.name in Map.get(action, :accept, [])) ->
:ok
action.type == :create and not is_nil(Map.get(field, :default)) ->
:ok
true ->
raise "Code interface for #{action.name} has optional argument #{key} but it is not optional"
end
default =
if field_type == :argument do
field.default
else
if action.type == :update || (action.type == :destroy && action.soft?) do
if is_nil(field.update_default) do
field.default
else
field.update_default
end
else
field.default
end
end
if is_function(default) do
quote do
unquote(Macro.escape(default)).()
end
else
quote do
unquote(Macro.escape(default))
end
end
end
@doc false
def without_optional(keys) do
Enum.map(keys, fn
{:optional, key} ->
key
key ->
key
end)
end
@doc false
def unwrap_calc_interface_args(keys, resource, arguments, function_head? \\ false) do
{Enum.map(keys, &unwrap_calc_interface_arg_binding(resource, arguments, &1, function_head?)),
Enum.map(keys, &unwrap_calc_interface_arg_access(&1))}
end
defp unwrap_calc_interface_arg_access({:optional, value}),
do: unwrap_calc_interface_arg_access(value)
defp unwrap_calc_interface_arg_access({:optional, value, _}),
do: unwrap_calc_interface_arg_access(value)
defp unwrap_calc_interface_arg_access(value) do
case value do
:_record ->
[type: :_record, name: :record, value: {:record, [], Elixir}]
{tag, name} ->
[type: tag, name: name, value: {name, [], Elixir}]
name ->
[type: :both, name: name, value: {name, [], Elixir}]
end
end
defp unwrap_calc_interface_arg_binding(resource, arguments, {:optional, binding}, head?) do
access = unwrap_calc_interface_arg_binding(resource, arguments, binding, head?)
if head? do
{:\\, [], [access, default_calc_value(resource, arguments, binding)]}
else
access
end
end
defp unwrap_calc_interface_arg_binding(
resource,
arguments,
{:optional, binding, default},
head?
) do
access = unwrap_calc_interface_arg_binding(resource, arguments, binding, head?)
if head? do
{:\\, [], [access, default]}
else
access
end
end
defp unwrap_calc_interface_arg_binding(_resource, _arguments, {tag, value}, _)
when tag in [:arg, :ref] do
{value, [], Elixir}
end
defp unwrap_calc_interface_arg_binding(resource, _arguments, :_record, false) do
{:=, [],
[
{:%, [], [{:__aliases__, [alias: false], [resource]}, {:%{}, [], []}]},
{:record, [], Elixir}
]}
end
defp unwrap_calc_interface_arg_binding(_resource, _arguments, value, _) do
{value, [], Elixir}
end
@doc false
def default_calc_value(_resource, arguments, {:arg, arg_name}) do
arguments
|> Enum.find(&(&1.name == arg_name))
|> case do
nil ->
nil
argument ->
argument.default
end
end
def default_calc_value(resource, _, {:ref, attribute}) do
resource
|> Ash.Resource.Info.attributes()
|> Enum.find(&(&1.name == attribute))
|> case do
nil ->
nil
attribute ->
attribute.default
end
end
def default_calc_value(resource, arguments, name) do
case default_calc_value(resource, arguments, {:arg, name}) do
nil ->
default_calc_value(resource, arguments, {:ref, name})
value ->
value
end
end
@doc false
# sobelow_skip ["DOS.BinToAtom", "DOS.StringToAtom"]
def resolve_calc_method_names(name) do
if name |> to_string() |> String.ends_with?("?") do
safe_name = name |> to_string() |> String.trim_trailing("?") |> String.to_atom()
bang_name = name
{safe_name, bang_name}
else
safe_name = name
bang_name = "#{name}!" |> String.to_atom()
{safe_name, bang_name}
end
end
# A common pattern is for a function to have both optional parameters and
# optional options. This usually comes in the form of two defaults:
#
# * An empty map for params.
# * An empty list for options.
#
# With those two defaults in mind, this function will decipher, from two inputs,
# which should be parameters and which should be options.
#
# Parameters can take one of two primary forms:
#
# 1. A map.
# 2. A list of maps for bulk operations.
#
# Additionally, if options are set explicitly (i.e. at least one option has
# been set), a keyword list will be converted to a map.
#
# ## Examples
#
# iex> params_and_opts(%{key: :param}, [key: :opt])
# {%{key: :param}, [key: :opt]}
#
# iex> params_and_opts([key: :opt], [])
# {%{}, [key: :opt]}
#
# iex> params_and_opts([], [])
# {[], []}
#
# iex> params_and_opts([%{key: :param}], [])
# {[%{key: :param}], []}
#
# iex> params_and_opts([key: :param], [key: :opt])
# {%{key: :param}, [key: :opt]}
@doc false
@spec params_and_opts(params_or_opts :: map() | [map()] | keyword(), keyword()) ::
{params :: map() | [map()], opts :: keyword()}
def params_and_opts(%{} = params, opts), do: {params, opts}
def params_and_opts([], opts), do: {[], opts}
def params_and_opts([%{} | _] = params_list, opts), do: {params_list, opts}
def params_and_opts(opts, []), do: {%{}, opts}
def params_and_opts(params_or_list, opts) do
params =
if Keyword.keyword?(params_or_list),
do: Map.new(params_or_list),
else: params_or_list
{params, opts}
end
@doc """
See `params_and_opts/2`.
Adds a post process function that can takes the opts and can further process,
validate, or transform them.
"""
@spec params_and_opts(
params_or_opts :: map() | [map()] | keyword(),
keyword(),
(keyword() -> keyword())
) ::
{params :: map() | [map()], opts :: keyword()}
def params_and_opts(params_or_opts, maybe_opts, post_process_opts_fn)
when is_function(post_process_opts_fn, 1) do
params_or_opts
|> params_and_opts(maybe_opts)
|> then(fn {params, opts} ->
{params,
opts
|> post_process_opts_fn.()}
end)
end
@deep_merge_keys [:bulk_options, :page]
# Selectively merges default opts into client-provided opts. For most keys,
# the value in opts will be used instead of the default if provided. However,
# certain options have special behavior:
#
# * #{Enum.map_join(@deep_merge_keys, ", ", &"`:#{&1}`")} - These
# options are deep merged, so if the default is a keyword list and the
# client value is a keyword list, they'll be merged.
# * `:load` - The default value and the client value will be combined in this
# case.
#
# ## Examples
#
# iex> merge_default_opts([key1: 1], key2: 2)
# [key2: 2, key1: 1]
#
# iex> merge_default_opts([key2: :client], key1: :default, key2: :default)
# [key2: :client, key1: :default]
#
# iex> merge_default_opts([page: false], page: [limit: 100])
# [page: false]
#
# iex> merge_default_opts([page: [offset: 2]], page: [limit: 100])
# [page: [limit: 100, offset: 2]]
#
# iex> merge_default_opts([load: [:calc2, :rel4]], load: [:calc1, rel1: [:rel2, :rel3]])
# [load: [:calc1, {:rel1, [:rel2, :rel3]}, :calc2, :rel4]]
@doc false
@spec merge_default_opts(keyword(), keyword()) :: keyword()
def merge_default_opts(opts, default_opts) do
Enum.reduce(default_opts, opts, fn {k, default}, opts ->
opts
|> Keyword.fetch(k)
|> case do
:error -> default
{:ok, value} -> merge_default_opt(k, default, value)
end
|> then(&Keyword.put(opts, k, &1))
end)
end
defp merge_default_opt(:load, default, value) do
List.wrap(default) ++ List.wrap(value)
end
defp merge_default_opt(key, default, value)
when key in @deep_merge_keys and is_list(default) and is_list(value),
do: Keyword.merge(default, value)
defp merge_default_opt(_key, _default, value), do: value
@doc """
Defines the code interface for a given resource + domain combination in the current module. For example:
```elixir
defmodule MyApp.Accounting do
require Ash.CodeInterface
Ash.CodeInterface.define_interface(MyApp.Accounting, MyApp.Accounting.Transaction)
Ash.CodeInterface.define_interface(MyApp.Accounting, MyApp.Accounting.Account)
Ash.CodeInterface.define_interface(MyApp.Accounting, MyApp.Accounting.Invoice)
end
```
"""
defmacro define_interface(domain, resource, definitions \\ nil) do
quote bind_quoted: [domain: domain, resource: resource, definitions: definitions],
generated: true,
location: :keep do
calculation_interfaces =
case definitions do
nil ->
Ash.Resource.Info.calculation_interfaces(resource)
definitions ->
Enum.filter(definitions, &match?(%Ash.Resource.CalculationInterface{}, &1))
end
interfaces =
case definitions do
nil ->
Ash.Resource.Info.interfaces(resource)
definitions ->
Enum.filter(definitions, &match?(%Ash.Resource.Interface{}, &1))
end
interfaces_for_defaults =
Enum.group_by(calculation_interfaces, fn interface ->
{interface.name, Enum.count(interface.args, &is_atom/1), Enum.count(interface.args)}
end)
for {{name, arity, optional_arity}, interfaces} <- interfaces_for_defaults do
args =
case interfaces do
[%{args: args, calculation: calculation}] ->
calculation = Ash.Resource.Info.calculation(resource, calculation)
{arg_bindings, _arg_access} =
args
|> Kernel.||([])
|> Ash.CodeInterface.unwrap_calc_interface_args(
resource,
calculation.arguments,
true
)
arg_bindings
multiple ->
multiple
|> Enum.map(fn interface ->
interface.args
|> Enum.flat_map(fn
{:optional, value} ->
calculation = Ash.Resource.Info.calculation(resource, interface.calculation)
[Ash.CodeInterface.default_calc_value(resource, calculation.arguments, value)]
{:optional, _, value} ->
[value]
_ ->
[]
end)
end)
|> Enum.uniq()
|> case do
[_] ->
interface = hd(multiple)
calculation = Ash.Resource.Info.calculation(resource, interface.calculation)
{arg_bindings, _arg_access} =
interface.args
|> Kernel.||([])
|> Ash.CodeInterface.unwrap_calc_interface_args(
resource,
calculation.arguments,
true
)
arg_bindings
_duplicates ->
raise """
The generated function #{name}/#{arity + optional_arity} would have
multiple different sets of default values for arguments. Please use a different
name for conflicting code interface functions.
"""
end
end
{safe_name, bang_name} = Ash.CodeInterface.resolve_calc_method_names(name)
def unquote(bang_name)(unquote_splicing(args), opts \\ [])
def unquote(safe_name)(unquote_splicing(args), opts \\ [])
end
for interface <- calculation_interfaces do
calculation = Ash.Resource.Info.calculation(resource, interface.calculation)
custom_inputs = Macro.escape(interface.custom_inputs)
{arg_bindings, arg_access} =
interface.args
|> Kernel.||([])
|> Ash.CodeInterface.unwrap_calc_interface_args(resource, calculation.arguments)
{safe_name, bang_name} = Ash.CodeInterface.resolve_calc_method_names(interface.name)
opts_location = Enum.count(arg_bindings)
interface_options = Ash.Resource.Interface.interface_options(:calculate, nil)
@doc """
#{calculation.description || "Calculates #{calculation.name} action on #{inspect(resource)}."}
#{Ash.CodeInterface.describe_calculation(resource, calculation, interface.args, interface.exclude_inputs, interface.custom_inputs)}
### Options
#{interface_options.docs()}
"""
|> Ash.CodeInterface.trim_double_newlines()
@doc spark_opts: [
{opts_location, interface_options.schema()}
]
def unquote(bang_name)(unquote_splicing(arg_bindings), opts) do
{refs, arguments, record} =
Enum.reduce(
[unquote_splicing(arg_access)],
{opts[:refs] || %{}, opts[:args] || %{}, nil},
fn config, {refs, arguments, record} ->
case config[:type] do
:_record ->
{refs, arguments, config[:value]}
:both ->
{Map.put(refs, config[:name], config[:value]),
Map.put(arguments, config[:name], config[:value]), record}
:ref ->
{Map.put(refs, config[:name], config[:value]), arguments, record}
:arg ->
{refs, Map.put(arguments, config[:name], config[:value]), record}
end
end
)
case Enum.filter(unquote(interface.exclude_inputs || []), fn input ->
Map.has_key?(arguments, input) || Map.has_key?(arguments, to_string(input))
end) do
[] ->
:ok
inputs ->
raise ArgumentError,
"Input(s) `#{Enum.join(inputs, ", ")}` not accepted by #{inspect(unquote(resource))}.#{unquote(interface.calculation)}/#{unquote(Enum.count(arg_bindings) + 1)}"
end
{arguments, custom_input_errors} =
Ash.CodeInterface.handle_custom_inputs(
arguments,
unquote(custom_inputs),
unquote(resource)
)
case custom_input_errors do
[] ->
opts =
[domain: unquote(domain), refs: refs, args: arguments, record: record] ++ opts
Ash.calculate!(unquote(resource), unquote(interface.calculation), opts)
errors ->
raise Ash.Error.to_error_class(errors)
end
end
@doc """
#{calculation.description || "Calculates #{calculation.name} action on #{inspect(resource)}."}
#{Ash.CodeInterface.describe_calculation(resource, calculation, interface.args, interface.exclude_inputs, interface.custom_inputs)}
### Options
#{interface_options.docs()}
"""
|> Ash.CodeInterface.trim_double_newlines()
@doc spark_opts: [
{opts_location, interface_options.schema()}
]
def unquote(safe_name)(unquote_splicing(arg_bindings), opts) do
{refs, arguments, record} =
Enum.reduce(
[unquote_splicing(arg_access)],
{opts[:refs] || %{}, opts[:args] || %{}, nil},
fn config, {refs, arguments, record} ->
case config[:type] do
:_record ->
{refs, arguments, config[:value]}
:both ->
{Map.put(refs, config[:name], config[:value]),
Map.put(arguments, config[:name], config[:value]), record}
:ref ->
{Map.put(refs, config[:name], config[:value]), arguments, record}
:arg ->
{refs, Map.put(arguments, config[:name], config[:value]), record}
end
end
)
case Enum.filter(unquote(interface.exclude_inputs || []), fn input ->
Map.has_key?(arguments, input) || Map.has_key?(arguments, to_string(input))
end) do
[] ->
:ok
inputs ->
raise ArgumentError,
"Input(s) `#{Enum.join(inputs, ", ")}` not accepted by #{inspect(unquote(resource))}.#{unquote(interface.calculation)}/#{unquote(Enum.count(arg_bindings) + 1)}"
end
{arguments, custom_input_errors} =
Ash.CodeInterface.handle_custom_inputs(
arguments,
unquote(custom_inputs),
unquote(resource)
)
case custom_input_errors do
[] ->
opts =
[domain: unquote(domain), refs: refs, args: arguments, record: record] ++ opts
Ash.calculate(unquote(resource), unquote(interface.calculation), opts)
errors ->
{:error, Ash.Error.to_error_class(errors)}
end
end
end
for interface <- interfaces do
action = Ash.CodeInterface.require_action(resource, interface)
filter_keys =
cond do
action.type not in [:read, :update, :destroy] ->
[]
interface.get_by_identity ->
Ash.Resource.Info.identity(resource, interface.get_by_identity).keys
interface.get_by ->
interface.get_by
true ->
[]
end
arg_names = Ash.CodeInterface.without_optional(interface.args || [])
all_args =
List.wrap(filter_keys) ++ arg_names
arg_vars = Enum.map(all_args, &{&1, [], Elixir})
arg_params = {:%{}, [], Enum.map(arg_names, fn arg -> {arg, {arg, [], Elixir}} end)}
filter_params = {:%{}, [], Enum.map(filter_keys, fn key -> {key, {key, [], Elixir}} end)}
arg_vars_function =
filter_keys
|> List.wrap()
|> Enum.concat(interface.args || [])
|> Enum.map(fn
{:optional, key} ->
default = Ash.CodeInterface.default_value(resource, action, key)
{:\\, [], [{key, [], Elixir}, default]}
key ->
{key, [], Elixir}
end)
if Enum.uniq(all_args) != all_args do
raise """
Arguments #{inspect(all_args)} for #{interface.name} are not unique!
"""
end
interface =
if Map.get(action, :get?) do
%{interface | get?: true}
else
interface
end
interface_options = Ash.Resource.Interface.interface_options(action.type, interface)
custom_inputs = Macro.escape(interface.custom_inputs)
resolve_params_and_opts =
quote do
{params, opts} =
Ash.CodeInterface.params_and_opts(
params_or_opts,
opts,
fn opts ->
default_options =
case unquote(Macro.escape(interface.default_options)) do
fun when is_function(fun, 0) -> fun.()
static_options -> static_options
end
opts
|> Ash.CodeInterface.merge_default_opts(default_options)
|> unquote(interface_options).validate!()
|> unquote(interface_options).to_options()
end
)
arg_params = unquote(arg_params)
params =
if is_list(params) do
Enum.map(params, fn item ->
if is_map(item) do
Map.merge(item, arg_params)
else
raise ArgumentError, """
Expected `params` to be a map or a list of maps.
Got: #{inspect(params)}
"""
end
end)
else
if is_map(params) do
Map.merge(params, arg_params)
else
raise ArgumentError, """
Expected `params` to be a map or a list of maps.
Got: #{inspect(params)}
"""
end
end
case Enum.filter(unquote(interface.exclude_inputs || []), fn input ->
if is_list(params) do
Enum.any?(
params,
&(Map.has_key?(&1, input) || Map.has_key?(&1, to_string(input)))
)
else
Map.has_key?(params, input) || Map.has_key?(params, to_string(input))
end
end) do
[] ->
:ok
inputs ->
raise ArgumentError,
"Input(s) `#{Enum.join(inputs, ", ")}` not accepted by #{inspect(unquote(resource))}.#{unquote(interface.name)}/#{unquote(Enum.count(interface.args || []) + 2)}"
end
{params, custom_input_errors} =
Ash.CodeInterface.handle_custom_inputs(
params,
unquote(custom_inputs),
unquote(resource)
)
filter_params = unquote(filter_params)
end
{subject, subject_args, resolve_subject, act, act!} =
case action.type do
:action ->
subject = quote do: input
resolve_subject =
quote do
{input_opts, opts} =
Keyword.split(opts, [
:input,
:actor,
:tenant,
:authorize?,
:tracer,
:scope,
:private_arguments
])
{input, input_opts} = Keyword.pop(input_opts, :input)
input_opts = Keyword.put(input_opts, :domain, unquote(domain))
case input do
%Ash.ActionInput{resource: unquote(resource)} ->
input
%Ash.ActionInput{resource: other_resource} ->
raise ArgumentError,
"Action input resource #{inspect(other_resource)} does not match expected resource #{inspect(unquote(resource))}."
input ->
input
end
input =
input
|> Kernel.||(unquote(resource))
|> Ash.ActionInput.for_action(unquote(action.name), params, input_opts)
|> Ash.ActionInput.add_error(custom_input_errors)
end
act = quote do: Ash.run_action(input, opts)
act! = quote do: Ash.run_action!(input, opts)
{subject, [], resolve_subject, act, act!}
:read ->
subject = quote do: query
resolve_subject =
quote do
{query_opts, opts} =
Keyword.split(opts, [
:query,
:actor,
:tenant,
:authorize?,
:tracer,
:context,
:scope
])
{query, query_opts} = Keyword.pop(query_opts, :query)
query_opts = Keyword.put(query_opts, :domain, unquote(domain))
query =
case query do
%Ash.Query{resource: unquote(resource)} = query ->
query
%Ash.Query{resource: other_resource} ->
raise ArgumentError,
"Query resource #{inspect(other_resource)} does not match expected resource #{inspect(unquote(resource))}."
unquote(resource) ->
unquote(resource)
|> Ash.Query.new()
other_resource
when is_atom(other_resource) and not is_nil(other_resource) ->
raise ArgumentError,
"Query resource #{inspect(other_resource)} does not match expected resource #{inspect(unquote(resource))}."
query ->
Ash.Query.build(unquote(resource), query || [])
end
|> Ash.Query.add_error(custom_input_errors)
query =
if unquote(filter_keys) && !Enum.empty?(unquote(filter_keys)) do
require Ash.Query
query
|> Ash.Query.for_read(unquote(action.name), params, query_opts)
|> Ash.Query.do_filter(filter_params)
else
Ash.Query.for_read(query, unquote(action.name), params, query_opts)
end
|> Ash.Query.add_error(custom_input_errors)
end
resolve_not_found_error? =
quote do
{not_found_error?, opts} = Keyword.pop(opts, :not_found_error?)
not_found_error? =
if not_found_error? != nil,
do: not_found_error?,
else: unquote(interface.not_found_error?)
end
act =
if interface.get? do
quote do
unquote(resolve_not_found_error?)
Ash.read_one(query, Keyword.drop(opts, [:stream?, :stream_options]))
|> case do
{:ok, nil} when not_found_error? ->
{:error,
Ash.Error.to_error_class(
Ash.Error.Query.NotFound.exception(resource: query.resource)
)}
result ->
result
end
end
else
quote do: Ash.read(query, Keyword.drop(opts, [:stream?, :stream_options]))
end
act! =
if interface.get? do
quote do
unquote(resolve_not_found_error?)
Ash.read_one!(query, Keyword.drop(opts, [:stream?, :stream_options]))
|> case do
nil when not_found_error? ->
raise Ash.Error.to_error_class(
Ash.Error.Query.NotFound.exception(resource: query.resource)
)
result ->
result
end
end
else
quote do
if opts[:stream?] do
opts =
Keyword.merge(opts, opts[:stream_options] || [])
|> Keyword.drop([:stream?, :stream_options])
Ash.stream!(query, Keyword.drop(opts, [:stream?, :stream_options]))
else
Ash.read!(query, Keyword.drop(opts, [:stream?, :stream_options]))
end
end
end
{subject, [], resolve_subject, act, act!}
:create ->
subject = quote do: changeset
resolve_subject =
quote do
{changeset, opts} = Keyword.pop(opts, :changeset)
{changeset_opts, opts} =
Keyword.split(opts, [
:actor,
:tenant,
:scope,
:authorize?,
:tracer,
:context,
:skip_unknown_inputs,
:private_arguments
])
changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain))
changeset =
if is_map(params) do
changeset
|> Kernel.||(unquote(resource))
|> case do
%Ash.Changeset{resource: unquote(resource)} ->
changeset
%Ash.Changeset{resource: other_resource} ->
raise ArgumentError,
"Changeset #{inspect(changeset)} does not match expected resource #{inspect(unquote(resource))}."
other_resource
when is_atom(other_resource) and other_resource != unquote(resource) ->
raise ArgumentError,
"Resource #{inspect(other_resource)} does not match expected resource #{inspect(unquote(resource))}."
changeset ->
changeset
end
|> Ash.Changeset.new()
|> Ash.Changeset.add_error(custom_input_errors)
|> Ash.Changeset.for_create(unquote(action.name), params, changeset_opts)
else
{:bulk, params}
end
end
act =
quote do
case changeset do
{:bulk, inputs} ->
if Enum.any?(custom_input_errors) do
%Ash.BulkResult{
errors: [Ash.Error.to_error_class(custom_input_errors)],
error_count: 1
}
else
bulk_opts =
opts
|> Keyword.delete(:bulk_options)
|> Keyword.put(:notify?, true)
|> Keyword.merge(Keyword.get(opts, :bulk_options, []))
|> Enum.concat(changeset_opts)
Ash.bulk_create(
inputs,
unquote(resource),
unquote(action.name),
bulk_opts
)
end
changeset ->
Ash.create(changeset, Keyword.delete(opts, :bulk_options))
end
end
act! =
quote do
case changeset do
{:bulk, inputs} ->
if Enum.any?(custom_input_errors) do
raise Ash.Error.to_error_class(custom_input_errors)
else
bulk_opts =
opts
|> Keyword.delete(:bulk_options)
|> Keyword.put(:notify?, true)
|> Keyword.merge(Keyword.get(opts, :bulk_options, []))
|> Enum.concat(changeset_opts)
Ash.bulk_create!(
inputs,
unquote(resource),
unquote(action.name),
bulk_opts
)
end
changeset ->
Ash.create!(changeset, Keyword.delete(opts, :bulk_options))
end
end
{subject, [], resolve_subject, act, act!}
:update ->
subject = quote do: changeset
subject_args =
if interface.require_reference? do
quote do: [record]
else
[]
end
resolve_subject =
if Enum.empty?(filter_keys) and interface.require_reference? do
quote do
{changeset_opts, opts} =
Keyword.split(opts, [
:actor,
:tenant,
:scope,
:authorize?,
:tracer,
:context,
:skip_unknown_inputs,
:private_arguments
])
changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain))
changeset =
record
|> case do
%Ash.Changeset{resource: unquote(resource)} ->
record
|> Ash.Changeset.filter(filter_params)
|> Ash.Changeset.add_error(custom_input_errors)
|> Ash.Changeset.for_update(
unquote(action.name),
params,
changeset_opts
)
%Ash.Changeset{resource: other_resource} ->
raise ArgumentError,
"Changeset #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}."
%struct{} = record when struct == unquote(resource) ->
record
|> Ash.Changeset.new()
|> Ash.Changeset.filter(filter_params)
|> Ash.Changeset.add_error(custom_input_errors)
|> Ash.Changeset.for_update(
unquote(action.name),
params,
changeset_opts
)
%Ash.Query{} = query ->
{:atomic, :query, query}
%other_resource{} when other_resource != unquote(resource) ->
raise ArgumentError,
"Record #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}."
[{_key, _val} | _] = id ->
{:atomic, :id, id}
list when is_list(list) ->
{:atomic, :stream, list}
other ->
{:atomic, :id, other}
end
end
else
quote do
{changeset_opts, opts} =
Keyword.split(opts, [
:actor,
:tenant,
:authorize?,
:scope,
:tracer,
:context,
:skip_unknown_inputs,
:private_arguments
])
changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain))
changeset =
{:atomic, :query, Ash.Query.do_filter(unquote(resource), filter_params)}
end
end
act =
quote do
case changeset do
{:atomic, method, id} ->
if Enum.any?(custom_input_errors) do
%Ash.BulkResult{
errors: [Ash.Error.to_error_class(custom_input_errors)],
error_count: 1
}
else
bulk_opts =
opts
|> Keyword.drop([:bulk_options, :atomic_upgrade?])
|> Keyword.merge(Keyword.get(opts, :bulk_options, []))
|> Enum.concat(changeset_opts)
|> Keyword.put(:resource, unquote(resource))
|> then(fn bulk_opts ->
if method == :id || unquote(interface.get?) do
authorize_with =
if Ash.DataLayer.data_layer_can?(__MODULE__, :expr_error) do
:error
else
:filter
end
bulk_opts
|> Keyword.put(:return_records?, true)
|> Keyword.put(:return_errors?, true)
|> Keyword.put_new(:authorize_with, authorize_with)
|> Keyword.put(:notify?, true)
else
bulk_opts
end
end)
|> Keyword.put_new(:strategy, [:atomic, :stream, :atomic_batches])
bulk_opts =
if method in [:stream, :query] do
Keyword.put(bulk_opts, :filter, filter_params)
else
bulk_opts
end
case Ash.CodeInterface.bulk_query(unquote(resource), method, id) do
{:ok, query} ->
query
|> Ash.bulk_update(unquote(action.name), params, bulk_opts)
|> case do
%Ash.BulkResult{} = result
when method in [:stream, :query] and not unquote(interface.get?) ->
result
%Ash.BulkResult{status: :success, records: [_, _ | _] = records}
when unquote(interface.get?) ->
{:error,
Ash.Error.Invalid.MultipleResults.exception(
count: Enum.count(records),
query: query
)}
%Ash.BulkResult{status: :success, records: [record]} = result ->
if opts[:return_notifications?] do
{:ok, record, result.notifications}
else
{:ok, record}
end
%Ash.BulkResult{status: :success, records: []} = result ->
{:error,
Ash.Error.to_error_class(
Ash.Error.Query.NotFound.exception(
resource: unquote(resource),
primary_key: id
)
)}
%Ash.BulkResult{status: :error, errors: errors} ->
{:error, Ash.Error.to_error_class(errors)}
end
{:error, error} ->
{:error, Ash.Error.to_error_class(error)}
end
end
changeset ->
Ash.update(changeset, Keyword.delete(opts, :bulk_options))
end
end
act! =
quote do
case changeset do
{:atomic, method, id} ->
if Enum.any?(custom_input_errors) do
raise Ash.Error.to_error_class(custom_input_errors)
else
bulk_opts =
opts
|> Keyword.drop([:bulk_options, :atomic_upgrade?])
|> Keyword.merge(Keyword.get(opts, :bulk_options, []))
|> Enum.concat(changeset_opts)
|> Keyword.put(:resource, unquote(resource))
|> then(fn bulk_opts ->
if method == :id || unquote(interface.get?) do
authorize_with =
if Ash.DataLayer.data_layer_can?(__MODULE__, :expr_error) do
:error
else
:filter
end
bulk_opts
|> Keyword.put(:return_records?, true)
|> Keyword.put(:return_errors?, true)
|> Keyword.put(:allow_stream_with, :full_read)
|> Keyword.put_new(:authorize_with, authorize_with)
|> Keyword.put(:notify?, true)
else
bulk_opts
end
end)
|> Keyword.put_new(:strategy, [:atomic, :stream, :atomic_batches])
bulk_opts =
if method in [:stream] do
Keyword.put(bulk_opts, :filter, filter_params)
else
bulk_opts
end
case Ash.CodeInterface.bulk_query(unquote(resource), method, id) do
{:ok, query} ->
query
|> Ash.bulk_update!(unquote(action.name), params, bulk_opts)
|> case do
%Ash.BulkResult{} = result
when method in [:stream, :query] and not unquote(interface.get?) ->
result
%Ash.BulkResult{status: :success, records: [_, _ | _] = records}
when unquote(interface.get?) ->
raise Ash.Error.to_error_class(
Ash.Error.Invalid.MultipleResults.exception(
count: Enum.count(records),
query: query
)
)
%Ash.BulkResult{status: :success, records: [record]} = result ->
if opts[:return_notifications?] do
{record, result.notifications}
else
record
end
%Ash.BulkResult{status: :success, records: []} = result ->
raise Ash.Error.to_error_class(
Ash.Error.Query.NotFound.exception(
resource: unquote(resource),
primary_key: id
)
)
end
{:error, error} ->
raise Ash.Error.to_error_class(error)
end
end
changeset ->
Ash.update!(changeset, Keyword.delete(opts, :bulk_options))
end
end
{subject, subject_args, resolve_subject, act, act!}
:destroy ->
subject = quote do: changeset
subject_args =
if interface.require_reference? do
quote do: [record]
else
[]
end
resolve_subject =
if interface.require_reference? do
quote do
{changeset_opts, opts} =
Keyword.split(opts, [
:actor,
:tenant,
:scope,
:authorize?,
:tracer,
:context,
:skip_unknown_inputs,
:private_arguments
])
changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain))
changeset =
record
|> case do
%Ash.Changeset{resource: unquote(resource)} ->
record
|> Ash.Changeset.filter(filter_params)
|> Ash.Changeset.add_error(custom_input_errors)
|> Ash.Changeset.for_destroy(
unquote(action.name),
params,
changeset_opts
)
%Ash.Changeset{resource: other_resource} ->
raise ArgumentError,
"Changeset #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}."
%struct{} = record when struct == unquote(resource) ->
record
|> Ash.Changeset.new()
|> Ash.Changeset.filter(filter_params)
|> Ash.Changeset.add_error(custom_input_errors)
|> Ash.Changeset.for_destroy(
unquote(action.name),
params,
changeset_opts
)
%Ash.Query{} = query ->
{:atomic, :query, query}
%other_resource{} when other_resource != unquote(resource) ->
raise ArgumentError,
"Record #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}."
[{_key, _val} | _] = id ->
{:atomic, :id, id}
list when is_list(list) ->
{:atomic, :stream, list}
other ->
{:atomic, :id, other}
end
end
else
quote do
{changeset_opts, opts} =
Keyword.split(opts, [
:actor,
:tenant,
:scope,
:authorize?,
:tracer,
:context,
:skip_unknown_inputs,
:private_arguments
])
changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain))
changeset =
{:atomic, :query, Ash.Query.do_filter(unquote(resource), filter_params)}
end
end
act =
quote do
case changeset do
{:atomic, method, id} ->
if Enum.any?(custom_input_errors) do
%Ash.BulkResult{
errors: [Ash.Error.to_error_class(custom_input_errors)],
error_count: 1
}
else
bulk_opts =
opts
|> Keyword.drop([:bulk_options, :return_destroyed?])
|> Keyword.merge(Keyword.get(opts, :bulk_options, []))
|> Enum.concat(changeset_opts)
|> Keyword.put(:resource, unquote(resource))
|> then(fn bulk_opts ->
if method == :id || unquote(interface.get?) do
authorize_with =
if Ash.DataLayer.data_layer_can?(__MODULE__, :expr_error) do
:error
else
:filter
end
bulk_opts
|> Keyword.put(:return_records?, true)
|> Keyword.put(:return_errors?, true)
|> Keyword.put(:allow_stream_with, :full_read)
|> Keyword.put_new(:authorize_with, authorize_with)
|> Keyword.put(:notify?, true)
else
Keyword.put(bulk_opts, :return_records?, opts[:return_destroyed?])
end
end)
|> Keyword.put_new(:strategy, [:atomic, :stream, :atomic_batches])
bulk_opts =
if method in [:stream, :query] do
Keyword.put(bulk_opts, :filter, filter_params)
else
bulk_opts
end
case Ash.CodeInterface.bulk_query(unquote(resource), method, id) do
{:ok, query} ->
query
|> Ash.bulk_destroy(unquote(action.name), params, bulk_opts)
|> case do
%Ash.BulkResult{} = result
when method in [:stream, :query] and not unquote(interface.get?) ->
result
%Ash.BulkResult{status: :success, records: [_, _ | _] = records}
when unquote(interface.get?) ->
{:error,
Ash.Error.Invalid.MultipleResults.exception(
count: Enum.count(records),
query: query
)}
%Ash.BulkResult{status: :success, records: [record]} = result ->
if opts[:return_destroyed?] do
if opts[:return_notifications?] do
{:ok, record, result.notifications}
else
{:ok, record}
end
else
if opts[:return_notifications?] do
{:ok, result.notifications}
else
:ok
end
end
%Ash.BulkResult{status: :success, records: empty} = result
when empty in [[], nil] and
(unquote(interface.get?) or method == :id) ->
{:error,
Ash.Error.to_error_class(
Ash.Error.Query.NotFound.exception(
resource: unquote(resource),
primary_key: id
)
)}
%Ash.BulkResult{status: :success, records: empty} = result
when empty in [[], nil] ->
if opts[:return_destroyed?] do
{:error,
Ash.Error.to_error_class(
Ash.Error.Query.NotFound.exception(
resource: unquote(resource),
primary_key: id
)
)}
else
if opts[:return_notifications?] do
{:ok, result.notifications}
else
:ok
end
end
%Ash.BulkResult{status: :error, errors: errors} ->
{:error, Ash.Error.to_error_class(errors)}
end
{:error, error} ->
{:error, Ash.Error.to_error_class(error)}
end
end
changeset ->
Ash.destroy(changeset, Keyword.delete(opts, :bulk_options))
end
end
act! =
quote do
case changeset do
{:atomic, method, id} ->
if Enum.any?(custom_input_errors) do
raise Ash.Error.to_error_class(custom_input_errors)
else
bulk_opts =
opts
|> Keyword.drop([:bulk_options, :return_destroyed?])
|> Keyword.merge(Keyword.get(opts, :bulk_options, []))
|> Enum.concat(changeset_opts)
|> Keyword.put(:resource, unquote(resource))
|> then(fn bulk_opts ->
if method == :id || unquote(interface.get?) do
authorize_with =
if Ash.DataLayer.data_layer_can?(__MODULE__, :expr_error) do
:error
else
:filter
end
bulk_opts
|> Keyword.put(:return_records?, true)
|> Keyword.put(:return_errors?, true)
|> Keyword.put(:allow_stream_with, :full_read)
|> Keyword.put_new(:authorize_with, authorize_with)
|> Keyword.put(:notify?, true)
else
Keyword.put(bulk_opts, :return_records?, opts[:return_destroyed?])
end
end)
|> Keyword.put_new(:strategy, [:atomic, :stream, :atomic_batches])
bulk_opts =
if method in [:stream, :query] do
Keyword.put(bulk_opts, :filter, filter_params)
else
bulk_opts
end
case Ash.CodeInterface.bulk_query(unquote(resource), method, id) do
{:ok, query} ->
query
|> Ash.bulk_destroy!(unquote(action.name), params, bulk_opts)
|> case do
%Ash.BulkResult{} = result
when method in [:stream, :query] and not unquote(interface.get?) ->
result
%Ash.BulkResult{status: :success, records: [_, _ | _] = records}
when unquote(interface.get?) ->
raise Ash.Error.to_error_class(
Ash.Error.Invalid.MultipleResults.exception(
count: Enum.count(records),
query: query
)
)
%Ash.BulkResult{status: :success, records: [record]} = result ->
if opts[:return_destroyed?] do
if opts[:return_notifications?] do
{record, result.notifications}
else
record
end
else
if opts[:return_notifications?] do
result.notifications
else
:ok
end
end
%Ash.BulkResult{status: :success, records: empty} = result
when empty in [[], nil] and
(unquote(interface.get?) or method == :id) ->
raise Ash.Error.to_error_class(
Ash.Error.Query.NotFound.exception(
resource: unquote(resource),
primary_key: id
)
)
%Ash.BulkResult{status: :success, records: empty} = result
when empty in [[], nil] ->
if opts[:return_destroyed?] do
raise Ash.Error.to_error_class(
Ash.Error.Query.NotFound.exception(
resource: unquote(resource),
primary_key: id
)
)
else
if opts[:return_notifications?] do
{:ok, result.notifications}
else
:ok
end
end
end
{:error, error} ->
raise Ash.Error.to_error_class(error)
end
end
changeset ->
Ash.destroy!(changeset, Keyword.delete(opts, :bulk_options))
end
end
{subject, subject_args, resolve_subject, act, act!}
end
subject_name = elem(subject, 0)
common_args =
quote do: [
unquote_splicing(subject_args),
unquote_splicing(arg_vars_function)
]
first_opts_location = Enum.count(subject_args) + Enum.count(arg_vars_function)
params_handling_bulk_empty_params =
if action.type == :create do
quote do
if params == [] and opts == nil do
{name, arity} = __ENV__.function
raise ArgumentError, """
Cannot provide an empty list for params `#{__MODULE__}.#{name}/#{arity}` without also specifying options.
We cannot tell the difference between an empty list of inputs and an empty list of options.
If you are trying to provide an empty list of options,
you should also specify empty `params`, i.e `#{name}(..., %{}, params)`
If you are trying to provide an empty list of records to create,
you should also specify empty `opts`, i.e `#{name}(..., params, [])`
"""
else
if Keyword.keyword?(params) and is_nil(opts) do
{%{}, params}
else
{params || %{}, opts || []}
end
end
end
else
quote do
keyword? = Keyword.keyword?(params)
if keyword? and is_nil(opts) do
{%{}, params}
else
if keyword? do
{Map.new(params), opts || []}
else
{params || %{}, opts || []}
end
end
end
end
@dialyzer {:nowarn_function, {interface.name, length(common_args) + 2}}
@doc """
#{action.description || "Calls the #{action.name} action on #{inspect(resource)}."}
#{Ash.CodeInterface.describe_action(resource, action, interface.args, interface.exclude_inputs, interface.custom_inputs)}
## Options
#{interface_options.docs()}
"""
|> Ash.CodeInterface.trim_double_newlines()
@doc spark_opts: [
{first_opts_location, interface_options.schema()},
{first_opts_location + 1, interface_options.schema()}
]
def unquote(interface.name)(
unquote_splicing(common_args),
params \\ nil,
opts \\ nil
) do
{params_or_opts, opts} = unquote(params_handling_bulk_empty_params)
unquote(resolve_params_and_opts)
unquote(resolve_subject)
unquote(act)
end
# sobelow_skip ["DOS.BinToAtom"]
@dialyzer {:nowarn_function, {:"#{interface.name}!", length(common_args) + 2}}
@doc """
#{action.description || "Calls the #{action.name} action on #{inspect(resource)}."}
Raises any errors instead of returning them
#{Ash.CodeInterface.describe_action(resource, action, interface.args, interface.exclude_inputs, interface.custom_inputs)}
## Options
#{interface_options.docs()}
"""
|> Ash.CodeInterface.trim_double_newlines()
@doc spark_opts: [
{first_opts_location, interface_options.schema()},
{first_opts_location + 1, interface_options.schema()}
]
def unquote(:"#{interface.name}!")(
unquote_splicing(common_args),
params \\ nil,
opts \\ nil
) do
{params_or_opts, opts} = unquote(params_handling_bulk_empty_params)
unquote(resolve_params_and_opts)
unquote(resolve_subject)
unquote(act!)
end
# sobelow_skip ["DOS.BinToAtom"]
if subject_name in [:changeset, :query, :input] do
subject_opts =
Keyword.take(interface_options.schema(), [
:actor,
:tenant,
:scope,
:authorize?,
:tracer,
:changeset,
:query,
:input
])
@dialyzer {:nowarn_function,
{:"#{subject_name}_to_#{interface.name}", length(common_args) + 2}}
@doc spark_opts: [
{first_opts_location, interface_options.schema()},
{first_opts_location + 1, interface_options.schema()}
]
@doc """
Returns the #{subject_name} corresponding to the action.
## Options
#{Spark.Options.docs(subject_opts)}
"""
|> Ash.CodeInterface.trim_double_newlines()
@doc spark_opts: [
{first_opts_location, subject_opts},
{first_opts_location + 1, subject_opts}
]
def unquote(:"#{subject_name}_to_#{interface.name}")(
unquote_splicing(common_args),
params_or_opts \\ %{},
opts \\ []
) do
unquote(resolve_params_and_opts)
unquote(resolve_subject)
unquote(subject)
end
end
# sobelow_skip ["DOS.BinToAtom"]
@doc """
Runs authorization checks for `#{inspect(resource)}.#{action.name}`
See `Ash.can/3` for more information
## Options
#{Ash.Resource.Interface.CanOpts.docs()}
"""
|> Ash.CodeInterface.trim_double_newlines()
@dialyzer {:nowarn_function, {:"can_#{interface.name}", length(common_args) + 3}}
@doc spark_opts: [
{first_opts_location + 1, Ash.Resource.Interface.CanOpts.schema()},
{first_opts_location + 2, Ash.Resource.Interface.CanOpts.schema()}
]
def unquote(:"can_#{interface.name}")(
actor,
unquote_splicing(common_args),
params_or_opts \\ %{},
opts \\ []
) do
{params, opts} =
Ash.CodeInterface.params_and_opts(params_or_opts, opts, fn opts ->
opts
|> Ash.Resource.Interface.CanOpts.validate!()
|> unquote(interface_options).to_options()
|> Keyword.put(:actor, actor)
end)
filter_params = unquote(filter_params)
arg_params = unquote(arg_params)
params =
if is_list(params),
do: Enum.map(params, &Map.merge(&1, arg_params)),
else: Map.merge(params, arg_params)
case Enum.filter(unquote(interface.exclude_inputs || []), fn input ->
Map.has_key?(params, input) || Map.has_key?(params, to_string(input))
end) do
[] ->
:ok
inputs ->
raise ArgumentError,
"Input(s) `#{Enum.join(inputs, ", ")}` not accepted by #{inspect(unquote(resource))}.#{unquote(interface.name)}/#{unquote(Enum.count(interface.args || []) + 2)}"
end
{params, custom_input_errors} =
Ash.CodeInterface.handle_custom_inputs(
params,
unquote(custom_inputs),
unquote(resource)
)
unquote(resolve_subject)
case unquote(subject) do
%struct{} when struct in [Ash.Changeset, Ash.Query, Ash.ActionInput] ->
Ash.can(unquote(subject), actor, opts)
{:atomic, _, %Ash.Query{} = query} = subj ->
Ash.CodeInterface.atomic_can(
query,
unquote(action.name),
actor,
opts,
params,
false
)
{:atomic, _, input} ->
raise "Ash.can_#{unquote(interface.name)} does not support #{inspect(input)} as input."
{:bulk, input} ->
raise "Ash.can_#{unquote(interface.name)} does not support #{inspect(input)} as input."
other ->
raise "Ash.can_#{unquote(interface.name)} does not support #{inspect(other)} as input."
end
end
# sobelow_skip ["DOS.BinToAtom"]
@dialyzer {:nowarn_function, {:"can_#{interface.name}?", length(common_args) + 3}}
@doc spark_opts: [
{first_opts_location + 1, Ash.Resource.Interface.CanQuestionMarkOpts.schema()},
{first_opts_location + 2, Ash.Resource.Interface.CanQuestionMarkOpts.schema()}
]
@doc """
Runs authorization checks for `#{inspect(resource)}.#{action.name}`, returning a boolean.
See `Ash.can?/3` for more information
## Options
#{Ash.Resource.Interface.CanQuestionMarkOpts.docs()}
"""
|> Ash.CodeInterface.trim_double_newlines()
def unquote(:"can_#{interface.name}?")(
actor,
unquote_splicing(common_args),
params_or_opts \\ %{},
opts \\ []
) do
{params, opts} =
Ash.CodeInterface.params_and_opts(params_or_opts, opts, fn opts ->
opts
|> Ash.Resource.Interface.CanOpts.validate!()
|> unquote(interface_options).to_options()
|> Keyword.put(:actor, actor)
end)
filter_params = unquote(filter_params)
arg_params = unquote(arg_params)
params =
if is_list(params),
do: Enum.map(params, &Map.merge(&1, arg_params)),
else: Map.merge(params, arg_params)
case Enum.filter(unquote(interface.exclude_inputs || []), fn input ->
Map.has_key?(params, input) || Map.has_key?(params, to_string(input))
end) do
[] ->
:ok
inputs ->
raise ArgumentError,
"Input(s) `#{Enum.join(inputs, ", ")}` not accepted by #{inspect(unquote(resource))}.#{unquote(interface.name)}/#{unquote(Enum.count(interface.args || []) + 2)}"
end
{params, custom_input_errors} =
Ash.CodeInterface.handle_custom_inputs(
params,
unquote(custom_inputs),
unquote(resource)
)
unquote(resolve_subject)
case unquote(subject) do
%struct{} when struct in [Ash.Changeset, Ash.Query, Ash.ActionInput] ->
Ash.can?(unquote(subject), actor, opts)
{:atomic, _, %Ash.Query{} = query} = subj ->
Ash.CodeInterface.atomic_can(
query,
unquote(action.name),
actor,
opts,
params,
true
)
{:atomic, _, input} = subj ->
raise "Ash.can_#{unquote(interface.name)}? does not support #{inspect(input)} as input."
{:bulk, input} ->
raise "Ash.can_#{unquote(interface.name)}? does not support #{inspect(input)} as input."
other ->
raise "Ash.can_#{unquote(interface.name)}? does not support #{inspect(other)} as input."
end
end
end
end
end
@doc false
def atomic_can(query, action_name, actor, opts, params, question_mark?) do
action_opts =
opts
|> Keyword.take([
:tenant,
:authorize?,
:tracer,
:context,
:skip_unknown_inputs
])
|> Keyword.put(:actor, actor)
query =
if query && !query.__validated_for_action__ do
Ash.Query.for_read(
query,
Ash.Resource.Info.primary_action!(query.resource, :read).name,
%{},
action_opts
)
else
query
end
changeset =
case Ash.Changeset.fully_atomic_changeset(
query.resource,
action_name,
params,
action_opts
) do
{:not_atomic, _} ->
if !opts[:data] or Enum.count_until(List.wrap(opts[:data]), 2) == 2 do
raise ArgumentError, """
The action #{action_name} could not be done atomically with the provided inputs.
You must pass the `data` option, containing a single record you are checking for authorization.
"""
else
Ash.Changeset.for_action(
Enum.at(List.wrap(opts[:data]), 0),
action_name,
params,
action_opts
)
end
changeset ->
changeset
end
case Ash.can(
query,
actor,
Keyword.merge(opts,
return_forbidden_error?: true,
maybe_is: false,
atomic_changeset: changeset,
filter_with: :filter,
alter_source?: true,
no_check?: true
)
) do
{:ok, true} ->
if question_mark? do
Ash.can?(
changeset,
actor,
opts
)
else
Ash.can(
changeset,
actor,
opts
)
end
{:ok, true, _query} ->
if question_mark? do
Ash.can?(
changeset,
actor,
opts
)
else
Ash.can(
changeset,
actor,
opts
)
end
{:ok, false, error} ->
if question_mark? do
false
else
{:error, error}
end
{:error, error} ->
if question_mark? do
false
else
{:error, error}
end
end
end
@doc false
def describe_action(resource, action, args, exclude_inputs, custom_inputs) do
resource
|> Ash.Resource.Info.action_inputs(action.name)
|> Enum.filter(&is_atom/1)
|> Enum.reject(&(&1 in exclude_inputs))
|> Enum.uniq()
|> case do
[] ->
""
inputs ->
{arguments, inputs} = Enum.split_with(inputs, &(&1 in (args || [])))
arguments = Enum.sort_by(arguments, fn arg -> Enum.find_index(args, &(&1 == arg)) end)
arguments =
Enum.map(arguments, &describe_input(resource, action, &1, custom_inputs))
inputs =
Enum.map(inputs, &describe_input(resource, action, &1, custom_inputs))
case {arguments, inputs} do
{[], []} ->
""
{arguments, []} ->
"""
# Arguments
#{Enum.join(arguments, "\n")}
"""
{[], inputs} ->
"""
# Inputs
#{Enum.join(inputs, "\n")}
"""
{arguments, inputs} ->
"""
# Arguments
#{Enum.join(arguments, "\n")}
# Inputs
#{Enum.join(inputs, "\n")}
"""
end
end
end
@doc false
def describe_calculation(resource, calculation, args, exclude_inputs, custom_inputs) do
calculation.arguments
|> Enum.map(& &1.name)
|> Enum.reject(&(&1 in exclude_inputs))
|> case do
[] ->
""
inputs ->
{arguments, inputs} = Enum.split_with(inputs, &(&1 in args))
arguments = Enum.sort_by(arguments, fn arg -> Enum.find_index(args, &(&1 == arg)) end)
arguments =
Enum.map(arguments, &describe_input(resource, calculation, &1, custom_inputs))
inputs =
Enum.map(inputs, &describe_input(resource, calculation, &1, custom_inputs))
case {arguments, inputs} do
{[], []} ->
""
{arguments, []} ->
"""
# Arguments
#{Enum.join(arguments, "\n")}
"""
{[], inputs} ->
"""
# Inputs
#{Enum.join(inputs, "\n")}
"""
{arguments, inputs} ->
"""
# Arguments
#{Enum.join(arguments, "\n")}
# Inputs
#{Enum.join(inputs, "\n")}
"""
end
end
end
defp describe_input(resource, %{arguments: arguments}, name, custom_inputs) do
case Enum.find(custom_inputs, &(&1.name == name)) do
nil ->
case Enum.find(arguments, &(&1.name == name)) do
nil ->
case Ash.Resource.Info.field(resource, name) do
nil ->
"* #{name}"
field ->
describe(field)
end
argument ->
describe(argument)
end
custom_input ->
describe(custom_input)
end
end
defp describe(%{name: name, description: description}) when not is_nil(description) do
"* #{name} - #{description}"
end
defp describe(%{name: name}) do
"* #{name}"
end
@doc false
def handle_custom_inputs(params, [], _resource) do
{params, []}
end
def handle_custom_inputs(params, custom_inputs, resource) do
Enum.reduce(custom_inputs, {params, []}, fn custom_input, {params, errors} ->
case fetch_key(params, custom_input.name) do
{:ok, key, value} ->
value = Ash.Type.Helpers.handle_indexed_maps(custom_input.type, value)
with {:ok, casted} <-
Ash.Type.cast_input(custom_input.type, value, custom_input.constraints),
{:ok, casted} <-
Ash.Type.apply_constraints(custom_input.type, casted, custom_input.constraints) do
if is_nil(casted) && !custom_input.allow_nil? do
error =
Ash.Error.Changes.Required.exception(
resource: resource,
field: custom_input.name,
type: :custom_input
)
{params, [error | errors]}
else
params = apply_custom_input_transform(params, casted, key, custom_input)
{params, errors}
end
else
:error ->
error =
Ash.Error.Invalid.InvalidCustomInput.exception(
field: custom_input.name,
message: "is invalid",
value: value
)
{params, [error | errors]}
{:error, error} when is_binary(error) ->
error =
Ash.Error.Invalid.InvalidCustomInput.exception(
field: custom_input.name,
message: error,
value: value
)
{params, [error | errors]}
{:error, keyword} when is_list(keyword) ->
if Keyword.keyword?(keyword) do
error =
if keyword[:field] do
Ash.Error.Invalid.InvalidCustomInput.exception(
field: keyword[:field],
message: keyword[:message],
value: keyword[:value],
vars: keyword
)
else
Ash.Error.Invalid.InvalidCustomInput.exception(
fields: keyword[:fields] || [],
message: keyword[:message],
value: keyword[:value],
vars: keyword
)
end
if keyword[:path] do
Ash.Error.set_path(error, keyword[:path])
else
error
end
else
error = Ash.Error.to_ash_error(keyword)
{params, [error | errors]}
end
{:error, error} ->
error = Ash.Error.to_ash_error(error)
{params, [error | errors]}
end
:error ->
if custom_input.allow_nil? do
{params, errors}
else
error =
Ash.Error.Changes.Required.exception(
resource: resource,
field: custom_input.name,
type: :custom_input
)
{params, [error | errors]}
end
end
end)
end
defp apply_custom_input_transform(params, casted, key, %{transform: nil}) do
Map.put(params, key, casted)
end
defp apply_custom_input_transform(params, casted, key, %{
transform: %{to: nil, using: nil}
}) do
Map.put(params, key, casted)
end
defp apply_custom_input_transform(params, casted, key, %{
transform: %{to: to, using: nil}
}) do
params |> Map.delete(key) |> Map.put(to, casted)
end
defp apply_custom_input_transform(params, casted, key, %{
transform: %{to: nil, using: using}
}) do
Map.put(params, key, using.(casted))
end
defp apply_custom_input_transform(params, casted, key, %{
transform: %{to: to, using: using}
}) do
params |> Map.delete(key) |> Map.put(to, using.(casted))
end
defp fetch_key(map, key) do
with {_key, :error} <- {key, Map.fetch(map, key)},
string_key = to_string(key),
{_key, :error} <- {string_key, Map.fetch(map, string_key)} do
:error
else
{key, {:ok, value}} ->
{:ok, key, value}
end
end
@doc false
def trim_double_newlines(str) do
str
|> String.replace(~r/\n{2,}/, "\n")
|> String.trim_trailing()
end
@doc false
def bulk_query(resource, method, id) do
case method do
:query ->
{:ok, id}
:stream ->
{:ok, id}
:id ->
case Ash.Filter.get_filter(resource, id) do
{:ok, filter} ->
{:ok, resource |> Ash.Query.do_filter(filter) |> Ash.Query.limit(1)}
{:error, error} ->
{:error, error}
end
end
end
end