mirror of
https://github.com/ash-project/ash.git
synced 2024-09-20 13:33:20 +12:00
003d5eb0e3
* reproduced reported error message. * remove path matching from code interface in order to make error error from issue 679 more clear. --------- Co-authored-by: Jordan <srcrr-gitlab@ipriva.com>
652 lines
20 KiB
Elixir
652 lines
20 KiB
Elixir
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 """
|
|
Defines the code interface for a given resource + api 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
|
|
```
|
|
|
|
Keep in mind that you can have this "automatically" defined in your resources by using the `define_for`
|
|
flag in a resource.
|
|
|
|
For example:
|
|
|
|
```elixir
|
|
defmodule MyApp.Accounting.Transaction do
|
|
use Ash.Resource
|
|
|
|
...
|
|
|
|
code_interface do
|
|
define_for MyApp.Accounting
|
|
|
|
define :start do
|
|
args [:invoice_id]
|
|
end
|
|
end
|
|
end
|
|
|
|
# Which can now be used like so:
|
|
|
|
MyApp.Accounting.Transaction.start!(invoice.id)
|
|
```
|
|
"""
|
|
defmacro define_interface(api, resource) do
|
|
quote bind_quoted: [api: api, resource: resource], generated: true, location: :keep do
|
|
interfaces_for_defaults =
|
|
Enum.group_by(Ash.Resource.Info.calculation_interfaces(resource), 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
|
|
|
|
# sobelow_skip ["DOS.BinToAtom"]
|
|
def unquote(:"#{name}!")(unquote_splicing(args), opts \\ [])
|
|
def unquote(name)(unquote_splicing(args), opts \\ [])
|
|
end
|
|
|
|
for interface <- Ash.Resource.Info.calculation_interfaces(resource) 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)
|
|
|
|
@doc "Calculate `#{calculation.name}`, raising any errors. See `#{interface.name}/#{Enum.count(interface.args) + 1}` for more."
|
|
# sobelow_skip ["DOS.BinToAtom"]
|
|
def unquote(:"#{interface.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
|
|
)
|
|
|
|
unquote(api).calculate!(unquote(resource), unquote(interface.calculation),
|
|
refs: refs,
|
|
args: arguments,
|
|
actor: opts[:actor],
|
|
record: record
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Calculate `#{calculation.name}`, returning `{:ok, result}` or `{:error, error}`.
|
|
#{if calculation.description, do: "\nDescription:" <> calculation.description}
|
|
"""
|
|
# sobelow_skip ["DOS.BinToAtom"]
|
|
def unquote(interface.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
|
|
)
|
|
|
|
unquote(api).calculate(unquote(resource), unquote(interface.calculation),
|
|
refs: refs,
|
|
args: arguments,
|
|
actor: opts[:actor],
|
|
record: record
|
|
)
|
|
end
|
|
end
|
|
|
|
for interface <- Ash.Resource.Info.interfaces(resource) do
|
|
action = Ash.CodeInterface.require_action(resource, interface)
|
|
|
|
filter_keys =
|
|
if action.type == :read do
|
|
if interface.get_by_identity do
|
|
Ash.Resource.Info.identity(resource, interface.get_by_identity).keys
|
|
else
|
|
if interface.get_by do
|
|
interface.get_by
|
|
end
|
|
end
|
|
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
|
|
|
|
doc = """
|
|
#{action.description || "Calls the #{action.name} action on the #{inspect(resource)} resource."}
|
|
|
|
## Options
|
|
|
|
#{Spark.OptionsHelpers.docs(Ash.Resource.Interface.interface_options(action.type))}
|
|
"""
|
|
|
|
resolve_opts_params =
|
|
quote do
|
|
{params, opts} =
|
|
if opts == [] && Keyword.keyword?(params_or_opts),
|
|
do: {%{}, params_or_opts},
|
|
else: {params_or_opts, opts}
|
|
|
|
params =
|
|
unquote(args)
|
|
|> Enum.zip([unquote_splicing(arg_vars)])
|
|
|> Enum.reduce(params, fn {key, value}, params ->
|
|
Map.put(params, key, value)
|
|
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 =
|
|
input
|
|
|> Kernel.||(unquote(resource))
|
|
|> Ash.ActionInput.for_action(unquote(action.name), params, input_opts)
|
|
end
|
|
|
|
act = quote do: unquote(api).run_action(input, opts)
|
|
act! = quote do: unquote(api).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])
|
|
|
|
{query, query_opts} = Keyword.pop(query_opts, :query)
|
|
|
|
query =
|
|
if unquote(filter_keys) do
|
|
require Ash.Query
|
|
{filters, params} = Map.split(params, unquote(filter_keys))
|
|
|
|
query
|
|
|> Kernel.||(unquote(resource))
|
|
|> Ash.Query.for_read(unquote(action.name), params, query_opts)
|
|
|> Ash.Query.filter(filters)
|
|
else
|
|
query
|
|
|> Kernel.||(unquote(resource))
|
|
|> Ash.Query.for_read(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? || action.get? do
|
|
quote do
|
|
unquote(resolve_not_found_error?)
|
|
|
|
unquote(api).read_one(query, opts)
|
|
|> case do
|
|
{:ok, nil} when not_found_error? ->
|
|
{:error, Ash.Error.Query.NotFound.exception(resource: query.resource)}
|
|
|
|
result ->
|
|
result
|
|
end
|
|
end
|
|
else
|
|
quote do: unquote(api).read(query, opts)
|
|
end
|
|
|
|
act! =
|
|
if interface.get? || action.get? do
|
|
quote do
|
|
unquote(resolve_not_found_error?)
|
|
|
|
unquote(api).read_one!(query, opts)
|
|
|> case do
|
|
nil when not_found_error? ->
|
|
raise Ash.Error.Query.NotFound, resource: query.resource
|
|
|
|
result ->
|
|
result
|
|
end
|
|
end
|
|
else
|
|
quote do: unquote(api).read!(query, opts)
|
|
end
|
|
|
|
{subject, [], resolve_subject, act, act!}
|
|
|
|
:create ->
|
|
subject = quote do: changeset
|
|
|
|
resolve_subject =
|
|
quote do
|
|
{changeset_opts, opts} =
|
|
Keyword.split(opts, [:changeset, :actor, :tenant, :authorize?, :tracer])
|
|
|
|
{changeset, changeset_opts} = Keyword.pop(changeset_opts, :changeset)
|
|
|
|
changeset =
|
|
changeset
|
|
|> Kernel.||(unquote(resource))
|
|
|> Ash.Changeset.for_create(unquote(action.name), params, changeset_opts)
|
|
end
|
|
|
|
act = quote do: unquote(api).create(changeset, opts)
|
|
act! = quote do: unquote(api).create!(changeset, opts)
|
|
|
|
{subject, [], resolve_subject, act, act!}
|
|
|
|
:update ->
|
|
subject = quote do: changeset
|
|
subject_args = quote do: [record]
|
|
|
|
resolve_subject =
|
|
quote do
|
|
{changeset_opts, opts} =
|
|
Keyword.split(opts, [:actor, :tenant, :authorize?, :tracer])
|
|
|
|
changeset =
|
|
record
|
|
|> Ash.Changeset.for_update(unquote(action.name), params, changeset_opts)
|
|
end
|
|
|
|
act = quote do: unquote(api).update(changeset, opts)
|
|
act! = quote do: unquote(api).update!(changeset, opts)
|
|
|
|
{subject, subject_args, resolve_subject, act, act!}
|
|
|
|
:destroy ->
|
|
subject = quote do: changeset
|
|
subject_args = quote do: [record]
|
|
|
|
resolve_subject =
|
|
quote do
|
|
{changeset_opts, opts} =
|
|
Keyword.split(opts, [:actor, :tenant, :authorize?, :tracer])
|
|
|
|
changeset =
|
|
record
|
|
|> Ash.Changeset.for_destroy(unquote(action.name), params, changeset_opts)
|
|
end
|
|
|
|
act = quote do: unquote(api).destroy(changeset, opts)
|
|
act! = quote do: unquote(api).destroy!(changeset, opts)
|
|
|
|
{subject, subject_args, resolve_subject, act, act!}
|
|
end
|
|
|
|
subject_name = elem(subject, 0)
|
|
|
|
resolve_subject =
|
|
quote do
|
|
unquote(resolve_subject)
|
|
unquote(subject) = %{unquote(subject) | api: unquote(api)}
|
|
end
|
|
|
|
common_args =
|
|
quote do: [
|
|
unquote_splicing(subject_args),
|
|
unquote_splicing(arg_vars_function),
|
|
params_or_opts \\ %{},
|
|
opts \\ []
|
|
]
|
|
|
|
@doc doc
|
|
@dialyzer {:nowarn_function, {interface.name, length(common_args)}}
|
|
def unquote(interface.name)(unquote_splicing(common_args)) do
|
|
unquote(resolve_opts_params)
|
|
unquote(resolve_subject)
|
|
unquote(act)
|
|
end
|
|
|
|
@doc doc
|
|
# sobelow_skip ["DOS.BinToAtom"]
|
|
@dialyzer {:nowarn_function, {:"#{interface.name}!", length(common_args)}}
|
|
def unquote(:"#{interface.name}!")(unquote_splicing(common_args)) do
|
|
unquote(resolve_opts_params)
|
|
unquote(resolve_subject)
|
|
unquote(act!)
|
|
end
|
|
|
|
# sobelow_skip ["DOS.BinToAtom"]
|
|
@dialyzer {:nowarn_function,
|
|
{:"#{subject_name}_to_#{interface.name}", length(common_args)}}
|
|
def unquote(:"#{subject_name}_to_#{interface.name}")(unquote_splicing(common_args)) do
|
|
unquote(resolve_opts_params)
|
|
unquote(resolve_subject)
|
|
unquote(subject)
|
|
end
|
|
|
|
# sobelow_skip ["DOS.BinToAtom"]
|
|
@dialyzer {:nowarn_function, {:"can_#{interface.name}", length(common_args) + 1}}
|
|
def unquote(:"can_#{interface.name}")(actor, unquote_splicing(common_args)) do
|
|
unquote(resolve_opts_params)
|
|
opts = Keyword.put(opts, :actor, actor)
|
|
unquote(resolve_subject)
|
|
unquote(api).can(unquote(subject), actor, opts)
|
|
end
|
|
|
|
# sobelow_skip ["DOS.BinToAtom"]
|
|
@dialyzer {:nowarn_function, {:"can_#{interface.name}?", length(common_args) + 1}}
|
|
def unquote(:"can_#{interface.name}?")(actor, unquote_splicing(common_args)) do
|
|
unquote(resolve_opts_params)
|
|
opts = Keyword.put(opts, :actor, actor)
|
|
unquote(resolve_subject)
|
|
unquote(api).can?(unquote(subject), actor, opts)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|