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)
unless 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
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
def without_optional(keys) do
Enum.map(keys, fn
{:optional, key} ->
key
key ->
key
end)
end
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
@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)
{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)}
### 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)],
{%{}, %{}, 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
)
opts = [domain: unquote(domain), refs: refs, args: arguments, record: record] ++ opts
Ash.calculate!(unquote(resource), unquote(interface.calculation), opts)
end
@doc """
#{calculation.description || "Calculates #{calculation.name} action on #{inspect(resource)}."}
#{Ash.CodeInterface.describe_calculation(resource, calculation, interface.args)}
### 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)],
{%{}, %{}, 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
)
opts = [domain: unquote(domain), refs: refs, args: arguments, record: record] ++ opts
Ash.calculate(unquote(resource), unquote(interface.calculation), opts)
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
args = List.wrap(filter_keys) ++ Ash.CodeInterface.without_optional(interface.args || [])
arg_vars = Enum.map(args, &{&1, [], Elixir})
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)
unless Enum.uniq(args) == args do
raise """
Arguments #{inspect(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)
resolve_opts_params =
quote do
{params, opts} =
if opts == [] && Keyword.keyword?(params_or_opts),
do:
{%{},
unquote(interface_options).validate!(params_or_opts)
|> unquote(interface_options).to_options()},
else:
{params_or_opts,
unquote(interface_options).validate!(opts)
|> unquote(interface_options).to_options()}
params =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params, fn {key, value}, params ->
Map.put(params, key, value)
end)
end
resolve_bang_opts_params =
quote do
{params, opts} =
if opts == [] && Keyword.keyword?(params_or_opts),
do:
{if(params_or_opts != [], do: %{}, else: []),
unquote(interface_options).validate!(params_or_opts)
|> unquote(interface_options).to_options()},
else:
{if(Keyword.keyword?(params_or_opts),
do: Map.new(params_or_opts),
else: params_or_opts
),
unquote(interface_options).validate!(opts)
|> unquote(interface_options).to_options()}
params =
if is_list(params) do
to_merge =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Map.new()
Enum.map(params, fn params ->
Map.merge(params, to_merge)
end)
else
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params, fn {key, value}, params ->
Map.put(params, key, value)
end)
end
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])
{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)
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])
{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
query =
if unquote(filter_keys) && !Enum.empty?(unquote(filter_keys)) do
require Ash.Query
{filters, params} = Map.split(params, unquote(filter_keys))
query
|> Ash.Query.for_read(unquote(action.name), params, query_opts)
|> Ash.Query.filter(filters)
else
Ash.Query.for_read(query, unquote(action.name), params, query_opts)
end
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.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.Query.NotFound, resource: query.resource
result ->
result
end
end
else
quote do
if opts[:stream?] do
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,
:authorize?,
:tracer,
:context,
:skip_unknown_inputs
])
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.for_create(unquote(action.name), params, changeset_opts)
else
{:bulk, params}
end
end
act =
quote do
case changeset do
{:bulk, inputs} ->
bulk_opts =
opts
|> Keyword.delete(:bulk_options)
|> Keyword.merge(Keyword.get(opts, :bulk_options, []))
|> Enum.concat(changeset_opts)
Ash.bulk_create(
inputs,
unquote(resource),
unquote(action.name),
bulk_opts
)
changeset ->
Ash.create(changeset, Keyword.delete(opts, :bulk_options))
end
end
act! =
quote do
case changeset do
{:bulk, inputs} ->
bulk_opts =
opts
|> Keyword.delete(:bulk_options)
|> Keyword.merge(Keyword.get(opts, :bulk_options, []))
|> Enum.concat(changeset_opts)
Ash.bulk_create!(
inputs,
unquote(resource),
unquote(action.name),
bulk_opts
)
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,
:authorize?,
:tracer,
:context,
:skip_unknown_inputs
])
changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain))
changeset =
record
|> case do
%Ash.Changeset{resource: unquote(resource)} ->
{filters, params} = Map.split(params, unquote(filter_keys))
record
|> Ash.Changeset.filter(filters)
|> 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) ->
{filters, params} = Map.split(params, unquote(filter_keys))
record
|> Ash.Changeset.new()
|> Ash.Changeset.filter(filters)
|> 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
filters = Map.take(params, unquote(filter_keys))
{changeset_opts, opts} =
Keyword.split(opts, [
:actor,
:tenant,
:authorize?,
:tracer,
:context,
:skip_unknown_inputs
])
changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain))
changeset =
{:atomic, :query, Ash.Query.do_filter(unquote(resource), filters)}
end
end
act =
quote do
{filters, params} = Map.split(params, unquote(filter_keys))
case changeset do
{:atomic, method, id} ->
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
bulk_opts
|> Keyword.put(:return_records?, true)
|> Keyword.put(:return_errors?, true)
|> Keyword.put_new(:authorize_with, :error)
|> 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, filters)
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: [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
changeset ->
Ash.update(changeset, Keyword.delete(opts, :bulk_options))
end
end
act! =
quote do
case changeset do
{:atomic, method, id} ->
{filters, params} = Map.split(params, unquote(filter_keys))
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
bulk_opts
|> Keyword.put(:return_records?, true)
|> Keyword.put(:return_errors?, true)
|> Keyword.put_new(:authorize_with, :error)
|> 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, filters)
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: [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
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,
:authorize?,
:tracer,
:context,
:skip_unknown_inputs
])
changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain))
changeset =
record
|> case do
%Ash.Changeset{resource: unquote(resource)} ->
{filters, params} = Map.split(params, unquote(filter_keys))
record
|> Ash.Changeset.filter(filters)
|> 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) ->
{filters, params} = Map.split(params, unquote(filter_keys))
record
|> Ash.Changeset.new()
|> Ash.Changeset.filter(filters)
|> 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
filters = Map.take(params, unquote(filter_keys))
{changeset_opts, opts} =
Keyword.split(opts, [
:actor,
:tenant,
:authorize?,
:tracer,
:context,
:skip_unknown_inputs
])
changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain))
changeset =
{:atomic, :query, Ash.Query.do_filter(unquote(resource), filters)}
end
end
act =
quote do
case changeset do
{:atomic, method, id} ->
{filters, params} = Map.split(params, unquote(filter_keys))
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
bulk_opts
|> Keyword.put(:return_records?, opts[:return_destroyed?])
|> Keyword.put(:return_errors?, true)
|> Keyword.put_new(:authorize_with, :error)
|> 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, filters)
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: [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] ->
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
changeset ->
Ash.destroy(changeset, Keyword.delete(opts, :bulk_options))
end
end
act! =
quote do
case changeset do
{:atomic, method, id} ->
{filters, params} = Map.split(params, unquote(filter_keys))
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
bulk_opts
|> Keyword.put(:return_records?, opts[:return_destroyed?])
|> Keyword.put(:return_errors?, true)
|> Keyword.put_new(:authorize_with, :error)
|> 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, filters)
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: [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] ->
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
changeset ->
Ash.destroy!(changeset, Keyword.delete(opts, :bulk_options))
end
end
{subject, subject_args, resolve_subject, act, act!}
end
subject_name = elem(subject, 0)
resolve_subject =
quote do
unquote(resolve_subject)
end
common_args =
quote do: [
unquote_splicing(subject_args),
unquote_splicing(arg_vars_function),
params_or_opts \\ %{},
opts \\ []
]
first_opts_location = Enum.count(subject_args) + Enum.count(arg_vars_function)
@doc """
#{action.description || "Calls the #{action.name} action on #{inspect(resource)}."}
#{Ash.CodeInterface.describe_action(resource, action, interface.args)}
## Options
#{interface_options.docs()}
"""
|> Ash.CodeInterface.trim_double_newlines()
@dialyzer {:nowarn_function, {interface.name, length(common_args)}}
@doc spark_opts: [
{first_opts_location, interface_options.schema()},
{first_opts_location + 1, interface_options.schema()}
]
def unquote(interface.name)(unquote_splicing(common_args)) do
unquote(resolve_opts_params)
unquote(resolve_subject)
unquote(act)
end
@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)}
## Options
#{interface_options.docs()}
"""
|> Ash.CodeInterface.trim_double_newlines()
# sobelow_skip ["DOS.BinToAtom"]
@dialyzer {:nowarn_function, {:"#{interface.name}!", length(common_args)}}
@doc spark_opts: [
{first_opts_location, interface_options.schema()},
{first_opts_location + 1, interface_options.schema()}
]
def unquote(:"#{interface.name}!")(unquote_splicing(common_args)) do
unquote(resolve_bang_opts_params)
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,
:authorize?,
:tracer,
:changeset,
:query,
:input
])
@dialyzer {:nowarn_function,
{:"#{subject_name}_to_#{interface.name}", length(common_args)}}
@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)) do
unquote(resolve_opts_params)
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) + 1}}
@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)) do
{params, opts} =
if opts == [] && Keyword.keyword?(params_or_opts),
do:
{%{},
Ash.Resource.Interface.CanOpts.validate!(params_or_opts)
|> unquote(interface_options).to_options()},
else:
{params_or_opts,
Ash.Resource.Interface.CanOpts.validate!(opts)
|> unquote(interface_options).to_options()}
opts = Keyword.put(opts, :actor, actor)
unquote(resolve_subject)
case unquote(subject) do
%struct{} when struct in [Ash.Changeset, Ash.Query, Ash.ActionInput] ->
Ash.can(unquote(subject), actor, opts)
{: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) + 1}}
@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)) do
{params, opts} =
if opts == [] && Keyword.keyword?(params_or_opts),
do:
{%{},
Ash.Resource.Interface.CanQuestionMarkOpts.validate!(params_or_opts)
|> unquote(interface_options).to_options()},
else:
{params_or_opts,
Ash.Resource.Interface.CanQuestionMarkOpts.validate!(opts)
|> unquote(interface_options).to_options()}
opts = Keyword.put(opts, :actor, actor)
unquote(resolve_subject)
case unquote(subject) do
%struct{} when struct in [Ash.Changeset, Ash.Query, Ash.ActionInput] ->
Ash.can?(unquote(subject), actor, opts)
{: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
end
end
end
def describe_action(resource, action, args) do
resource
|> Ash.Resource.Info.action_inputs(action.name)
|> Enum.filter(&is_atom/1)
|> Enum.uniq()
|> case do
[] ->
""
inputs ->
{arguments, inputs} = Enum.split_with(inputs, &(&1 in (args || [])))
arguments =
Enum.map(arguments, &describe_input(resource, action, &1))
inputs =
Enum.map(inputs, &describe_input(resource, action, &1))
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
def describe_calculation(resource, calculation, args) do
calculation.arguments
|> Enum.map(& &1.name)
|> 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))
inputs =
Enum.map(inputs, &describe_input(resource, calculation, &1))
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) do
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
end
defp describe(%{name: name, description: description}) when not is_nil(description) do
"* #{name} - #{description}"
end
defp describe(%{name: name}) do
"* #{name}"
end
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, Ash.Query.do_filter(resource, filter)}
{:error, error} ->
{:error, error}
end
end
end
end