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