improvement: support require_reference?: false on code interfaces

improvement: support `:filter` option on bulk create/destroy
This commit is contained in:
Zach Daniel 2024-04-13 17:20:59 -04:00
parent 9c74e52bd8
commit bc69f904e2
12 changed files with 278 additions and 99 deletions

View file

@ -170,6 +170,7 @@ spark_locals_without_parens = [
require_atomic?: 1,
require_attributes: 1,
require_primary_key?: 1,
require_reference?: 1,
required?: 1,
resource: 1,
resource: 2,

View file

@ -122,9 +122,10 @@ define :get_user_by_id, User, action: :get_by_id, args: [:id], get?: true
| [`action`](#resources-resource-define-action){: #resources-resource-define-action } | `atom` | | The name of the action that will be called. Defaults to the same name as the function. |
| [`args`](#resources-resource-define-args){: #resources-resource-define-args } | `list(atom \| {:optional, atom})` | | Map specific arguments to named inputs. Can provide any argument/attributes that the action allows. |
| [`not_found_error?`](#resources-resource-define-not_found_error?){: #resources-resource-define-not_found_error? } | `boolean` | `true` | If the action or interface is configured with `get?: true`, this determines whether or not an error is raised or `nil` is returned. |
| [`get?`](#resources-resource-define-get?){: #resources-resource-define-get? } | `boolean` | | Expects to only receive a single result from a read action, and returns a single result instead of a list. Ignored for other action types. |
| [`get_by`](#resources-resource-define-get_by){: #resources-resource-define-get_by } | `atom \| list(atom)` | | Takes a list of fields and adds those fields as arguments, which will then be used to filter. Sets `get?` to true automatically. Ignored for non-read actions. |
| [`get_by_identity`](#resources-resource-define-get_by_identity){: #resources-resource-define-get_by_identity } | `atom` | | Only relevant for read actions. Takes an identity, and gets its field list, performing the same logic as `get_by` once it has the list of fields. |
| [`require_reference?`](#resources-resource-define-require_reference?){: #resources-resource-define-require_reference? } | `boolean` | `true` | For update and destroy actions, require a resource or identifier to be passed in as the first argument. Not relevant for other action types. |
| [`get?`](#resources-resource-define-get?){: #resources-resource-define-get? } | `boolean` | | Expects to only receive a single result from a read action or a bulk update/destroy, and returns a single result instead of a list. Sets `require_reference?` to false automatically. |
| [`get_by`](#resources-resource-define-get_by){: #resources-resource-define-get_by } | `atom \| list(atom)` | | Takes a list of fields and adds those fields as arguments, which will then be used to filter. Sets `get?` to true and `require_reference?` to false automatically. Adds filters for read, update and destroy actions, replacing the `record` first argument. |
| [`get_by_identity`](#resources-resource-define-get_by_identity){: #resources-resource-define-get_by_identity } | `atom` | | Takes an identity, gets its field list, and performs the same logic as `get_by` with those fields. Adds filters for read, update and destroy actions, replacing the `record` first argument. |

View file

@ -2032,9 +2032,10 @@ define :get_user_by_id, action: :get_by_id, args: [:id], get?: true
| [`action`](#code_interface-define-action){: #code_interface-define-action } | `atom` | | The name of the action that will be called. Defaults to the same name as the function. |
| [`args`](#code_interface-define-args){: #code_interface-define-args } | `list(atom \| {:optional, atom})` | | Map specific arguments to named inputs. Can provide any argument/attributes that the action allows. |
| [`not_found_error?`](#code_interface-define-not_found_error?){: #code_interface-define-not_found_error? } | `boolean` | `true` | If the action or interface is configured with `get?: true`, this determines whether or not an error is raised or `nil` is returned. |
| [`get?`](#code_interface-define-get?){: #code_interface-define-get? } | `boolean` | | Expects to only receive a single result from a read action, and returns a single result instead of a list. Ignored for other action types. |
| [`get_by`](#code_interface-define-get_by){: #code_interface-define-get_by } | `atom \| list(atom)` | | Takes a list of fields and adds those fields as arguments, which will then be used to filter. Sets `get?` to true automatically. Ignored for non-read actions. |
| [`get_by_identity`](#code_interface-define-get_by_identity){: #code_interface-define-get_by_identity } | `atom` | | Only relevant for read actions. Takes an identity, and gets its field list, performing the same logic as `get_by` once it has the list of fields. |
| [`require_reference?`](#code_interface-define-require_reference?){: #code_interface-define-require_reference? } | `boolean` | `true` | For update and destroy actions, require a resource or identifier to be passed in as the first argument. Not relevant for other action types. |
| [`get?`](#code_interface-define-get?){: #code_interface-define-get? } | `boolean` | | Expects to only receive a single result from a read action or a bulk update/destroy, and returns a single result instead of a list. Sets `require_reference?` to false automatically. |
| [`get_by`](#code_interface-define-get_by){: #code_interface-define-get_by } | `atom \| list(atom)` | | Takes a list of fields and adds those fields as arguments, which will then be used to filter. Sets `get?` to true and `require_reference?` to false automatically. Adds filters for read, update and destroy actions, replacing the `record` first argument. |
| [`get_by_identity`](#code_interface-define-get_by_identity){: #code_interface-define-get_by_identity } | `atom` | | Takes an identity, gets its field list, and performs the same logic as `get_by` with those fields. Adds filters for read, update and destroy actions, replacing the `record` first argument. |

View file

@ -462,6 +462,11 @@ defmodule Ash do
doc:
"A select statement to apply to records. Ignored if `return_records?` is not true."
],
filter: [
type: :any,
doc:
"A filter to apply to records. This is also applied to a stream of inputs."
],
strategy: [
type: {:wrap_list, {:one_of, [:atomic, :atomic_batches, :stream]}},
default: [:atomic],
@ -524,6 +529,11 @@ defmodule Ash do
doc:
"The strategy or strategies to enable. :stream is used in all cases if the data layer does not support atomics."
],
filter: [
type: :any,
doc:
"A filter to apply to records. This is also applied to a stream of inputs."
],
skip_unknown_inputs: [
type: {:list, {:or, [:atom, :string]}},
doc:

View file

@ -120,6 +120,9 @@ defmodule Ash.Actions.Destroy.Bulk do
|> Keyword.put(:domain, domain)
|> Keyword.take(Ash.stream_opt_keys())
query =
Ash.Query.do_filter(query, opts[:filter])
run(
domain,
Ash.stream!(
@ -661,6 +664,7 @@ defmodule Ash.Actions.Destroy.Bulk do
tracer: opts[:tracer],
atomic_changeset: atomic_changeset,
return_errors?: opts[:return_errors?],
filter: opts[:filter],
return_notifications?: opts[:return_notifications?],
notify?: opts[:notify?],
return_records?: opts[:return_records?],
@ -833,6 +837,7 @@ defmodule Ash.Actions.Destroy.Bulk do
resource
|> Ash.Changeset.new()
|> Ash.Changeset.filter(opts[:filter])
|> Map.put(:domain, domain)
|> Ash.Actions.Helpers.add_context(opts)
|> Ash.Changeset.set_context(opts[:context] || %{})

View file

@ -85,6 +85,9 @@ defmodule Ash.Actions.Update.Bulk do
}
_ ->
query =
Ash.Query.do_filter(query, opts[:filter])
run(
domain,
Ash.stream!(
@ -109,6 +112,9 @@ defmodule Ash.Actions.Update.Bulk do
}
atomic_changeset ->
atomic_changeset =
Ash.Changeset.filter(atomic_changeset, opts[:filter])
{atomic_changeset, opts} =
Ash.Actions.Helpers.set_context_and_get_opts(domain, atomic_changeset, opts)
@ -655,6 +661,7 @@ defmodule Ash.Actions.Update.Bulk do
tracer: opts[:tracer],
atomic_changeset: atomic_changeset,
return_errors?: opts[:return_errors?],
filter: opts[:filter],
return_notifications?: opts[:return_notifications?],
notify?: opts[:notify?],
return_records?: opts[:return_records?],
@ -899,6 +906,7 @@ defmodule Ash.Actions.Update.Bulk do
resource
|> Ash.Changeset.new()
|> Map.put(:domain, domain)
|> Ash.Changeset.filter(opts[:filter])
|> Ash.Actions.Helpers.add_context(opts)
|> Ash.Changeset.set_context(opts[:context] || %{})
|> Ash.Changeset.prepare_changeset_for_action(action, opts)

View file

@ -86,6 +86,7 @@ defmodule Ash.Actions.Update do
|> Ash.Changeset.set_context(%{data_layer: %{use_atomic_update_data?: true}})
|> Map.put(:load, changeset.load)
|> Map.put(:select, changeset.select)
|> Map.put(:filter, changeset.filter)
|> Ash.Changeset.set_context(changeset.context)
{atomic_changeset, opts} =

View file

@ -4899,6 +4899,10 @@ defmodule Ash.Changeset do
Used by optimistic locking. See `Ash.Resource.Change.Builtins.optimistic_lock/1` for more.
"""
@spec filter(t(), Ash.Expr.t()) :: t()
def filter(changeset, expr) when expr in [nil, %{}, []] do
changeset
end
def filter(changeset, expr) do
if Ash.DataLayer.data_layer_can?(changeset.resource, :changeset_filter) do
%{

View file

@ -411,7 +411,7 @@ defmodule Ash.CodeInterface do
filter_keys =
cond do
action.type != :read ->
action.type not in [:read, :update, :destroy] ->
[]
interface.get_by_identity ->
@ -736,64 +736,93 @@ defmodule Ash.CodeInterface do
:update ->
subject = quote do: changeset
subject_args = quote do: [record]
subject_args =
if interface.require_reference? do
quote do: [record]
else
[]
end
resolve_subject =
quote do
{changeset_opts, opts} =
Keyword.split(opts, [:actor, :tenant, :authorize?, :tracer, :context])
if Enum.empty?(filter_keys) do
quote do
{changeset_opts, opts} =
Keyword.split(opts, [:actor, :tenant, :authorize?, :tracer, :context])
changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain))
changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain))
changeset =
record
|> case do
%Ash.Changeset{resource: unquote(resource)} ->
Ash.Changeset.for_update(
record,
unquote(action.name),
params,
changeset_opts
)
changeset =
record
|> case do
%Ash.Changeset{resource: unquote(resource)} ->
{filters, params} = Map.split(params, unquote(filter_keys))
%Ash.Changeset{resource: other_resource} ->
raise ArgumentError,
"Changeset #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}."
record
|> Ash.Changeset.filter(filters)
|> Ash.Changeset.for_update(
unquote(action.name),
params,
changeset_opts
)
%other_resource{} when other_resource != unquote(resource) ->
raise ArgumentError,
"Record #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}."
%Ash.Changeset{resource: other_resource} ->
raise ArgumentError,
"Changeset #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}."
%struct{} = record when struct == unquote(resource) ->
Ash.Changeset.for_update(
record,
unquote(action.name),
params,
changeset_opts
)
%other_resource{} when other_resource != unquote(resource) ->
raise ArgumentError,
"Record #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}."
%Ash.Query{} = query ->
{:atomic, :query, query}
%struct{} = record when struct == unquote(resource) ->
{filters, params} = Map.split(params, unquote(filter_keys))
value when is_function(value) ->
{:atomic, :stream, value}
record
|> Ash.Changeset.new()
|> Ash.Changeset.filter(filters)
|> Ash.Changeset.for_update(
unquote(action.name),
params,
changeset_opts
)
%Stream{} = value ->
{:atomic, :stream, value}
%Ash.Query{} = query ->
{:atomic, :query, query}
[{_key, _val} | _] = id ->
{:atomic, :id, id}
value when is_function(value) ->
{:atomic, :stream, value}
list when is_list(list) ->
{:atomic, :stream, list}
%Stream{} = value ->
{:atomic, :stream, value}
other ->
{:atomic, :id, other}
end
[{_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])
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 =
@ -811,12 +840,20 @@ defmodule Ash.CodeInterface do
end
end)
bulk_opts =
if method == :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 == :stream ->
%Ash.BulkResult{} = result
when method == :stream and unquote(Enum.empty?(filter_keys)) ->
result
%Ash.BulkResult{status: :success, records: [record]} = result ->
@ -848,6 +885,8 @@ defmodule Ash.CodeInterface do
quote do
case changeset do
{:atomic, method, id} ->
{filters, params} = Map.split(params, unquote(filter_keys))
bulk_opts =
opts
|> Keyword.delete(:bulk_options)
@ -863,12 +902,20 @@ defmodule Ash.CodeInterface do
end
end)
bulk_opts =
if method == :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 == :stream ->
%Ash.BulkResult{} = result
when method == :stream and unquote(Enum.empty?(filter_keys)) ->
result
%Ash.BulkResult{status: :success, records: [record]} = result ->
@ -896,66 +943,95 @@ defmodule Ash.CodeInterface do
:destroy ->
subject = quote do: changeset
subject_args = quote do: [record]
subject_args =
if interface.require_reference? do
quote do: [record]
else
[]
end
resolve_subject =
quote do
{changeset_opts, opts} =
Keyword.split(opts, [:actor, :tenant, :authorize?, :tracer, :context])
if interface.require_reference? do
quote do
{changeset_opts, opts} =
Keyword.split(opts, [:actor, :tenant, :authorize?, :tracer, :context])
changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain))
changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain))
changeset =
record
|> case do
%Ash.Changeset{resource: unquote(resource)} ->
Ash.Changeset.for_destroy(
record,
unquote(action.name),
params,
changeset_opts
)
changeset =
record
|> case do
%Ash.Changeset{resource: unquote(resource)} ->
{filters, params} = Map.split(params, unquote(filter_keys))
%Ash.Changeset{resource: other_resource} ->
raise ArgumentError,
"Changeset #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}."
record
|> Ash.Changeset.filter(filters)
|> Ash.Changeset.for_destroy(
unquote(action.name),
params,
changeset_opts
)
%other_resource{} when other_resource != unquote(resource) ->
raise ArgumentError,
"Record #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}."
%Ash.Changeset{resource: other_resource} ->
raise ArgumentError,
"Changeset #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}."
%struct{} = record when struct == unquote(resource) ->
Ash.Changeset.for_destroy(
record,
unquote(action.name),
params,
changeset_opts
)
%other_resource{} when other_resource != unquote(resource) ->
raise ArgumentError,
"Record #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}."
%Ash.Query{} = query ->
{:atomic, :query, query}
%struct{} = record when struct == unquote(resource) ->
{filters, params} = Map.split(params, unquote(filter_keys))
value when is_function(value) ->
{:atomic, :stream, value}
record
|> Ash.Changeset.new()
|> Ash.Changeset.filter(filters)
|> Ash.Changeset.for_destroy(
unquote(action.name),
params,
changeset_opts
)
%Stream{} = value ->
{:atomic, :stream, value}
%Ash.Query{} = query ->
{:atomic, :query, query}
[{_key, _val} | _] = id ->
{:atomic, :id, id}
value when is_function(value) ->
{:atomic, :stream, value}
list when is_list(list) ->
{:atomic, :stream, list}
%Stream{} = value ->
{:atomic, :stream, value}
other ->
{:atomic, :id, other}
end
[{_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])
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?])
@ -971,12 +1047,20 @@ defmodule Ash.CodeInterface do
end
end)
bulk_opts =
if method == :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_destroy(unquote(action.name), params, bulk_opts)
|> case do
%Ash.BulkResult{} = result when method == :stream ->
%Ash.BulkResult{} = result
when method == :stream and unquote(Enum.empty?(filter_keys)) ->
result
%Ash.BulkResult{status: :success, records: [record]} = result ->
@ -1013,6 +1097,8 @@ defmodule Ash.CodeInterface do
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?])
@ -1028,12 +1114,20 @@ defmodule Ash.CodeInterface do
end
end)
bulk_opts =
if method == :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_destroy!(unquote(action.name), params, bulk_opts)
|> case do
%Ash.BulkResult{} = result when method == :stream ->
%Ash.BulkResult{} = result
when method == :stream and unquote(Enum.empty?(filter_keys)) ->
result
%Ash.BulkResult{status: :success, records: [record]} = result ->

View file

@ -62,6 +62,16 @@ defmodule Ash.Resource.Change.Builtins do
@relate_actor_opts
end
@doc """
Applies a filter to the changeset. Has no effect for create actions.
This ensures that only things matching the provided filter are updated or destroyed.
"""
@spec filter(expr :: Ash.Expr.t()) :: Ash.Resource.Change.ref()
def filter(filter) do
{Ash.Resource.Change.Filter, filter: filter}
end
@spec relate_actor(relationship :: atom, opts :: Keyword.t()) :: Ash.Resource.Change.ref()
def relate_actor(relationship, opts \\ []) do
opts =

View file

@ -0,0 +1,14 @@
defmodule Ash.Resource.Change.Filter do
@moduledoc false
use Ash.Resource.Change
@impl true
def change(changeset, opts, _) do
Ash.Changeset.filter(changeset, opts[:filter])
end
@impl true
def atomic(changeset, opts, context) do
{:ok, change(changeset, opts, context)}
end
end

View file

@ -2,18 +2,42 @@ defmodule Ash.Resource.Interface do
@moduledoc """
Represents a function in a resource's code interface
"""
defstruct [:name, :action, :args, :get?, :get_by, :get_by_identity, :not_found_error?]
defstruct [
:name,
:action,
:args,
:get?,
:get_by,
:get_by_identity,
:not_found_error?,
require_reference?: true
]
@type t :: %__MODULE__{}
def transform(definition) do
{:ok,
definition
|> set_get?()
|> set_require_reference?()}
end
defp set_get?(definition) do
if definition.get_by || definition.get_by_identity do
{:ok, %{definition | get?: true}}
%{definition | get?: true}
else
{:ok, definition}
definition
end
end
defp set_require_reference?(%{get?: true} = definition) do
%{definition | require_reference?: false}
end
defp set_require_reference?(definition) do
definition
end
def interface_options(:calculate) do
[
actor: [
@ -125,22 +149,28 @@ defmodule Ash.Resource.Interface do
doc:
"If the action or interface is configured with `get?: true`, this determines whether or not an error is raised or `nil` is returned."
],
require_reference?: [
type: :boolean,
default: true,
doc:
"For update and destroy actions, require a resource or identifier to be passed in as the first argument. Not relevant for other action types."
],
get?: [
type: :boolean,
doc: """
Expects to only receive a single result from a read action, and returns a single result instead of a list. Ignored for other action types.
Expects to only receive a single result from a read action or a bulk update/destroy, and returns a single result instead of a list. Sets `require_reference?` to false automatically.
"""
],
get_by: [
type: {:wrap_list, :atom},
doc: """
Takes a list of fields and adds those fields as arguments, which will then be used to filter. Sets `get?` to true automatically. Ignored for non-read actions.
Takes a list of fields and adds those fields as arguments, which will then be used to filter. Sets `get?` to true and `require_reference?` to false automatically. Adds filters for read, update and destroy actions, replacing the `record` first argument.
"""
],
get_by_identity: [
type: :atom,
doc: """
Only relevant for read actions. Takes an identity, and gets its field list, performing the same logic as `get_by` once it has the list of fields.
Takes an identity, gets its field list, and performs the same logic as `get_by` with those fields. Adds filters for read, update and destroy actions, replacing the `record` first argument.
"""
]
]