Current section

Files

Jump to
cinder lib cinder bulk_action_executor.ex
Raw

lib/cinder/bulk_action_executor.ex

defmodule Cinder.BulkActionExecutor do
@moduledoc """
Executes bulk actions on selected records.
This module is used internally by `Cinder.LiveComponent` to execute bulk actions
defined in `bulk_action` slots. It handles both Ash action atoms and function
captures.
## Atom Actions
When given an atom, introspects the resource to determine if it's an update or
destroy action, then calls `Ash.bulk_update/4` or `Ash.bulk_destroy/4`:
BulkActionExecutor.execute(:archive,
resource: MyApp.User,
ids: ["1", "2"],
actor: current_user
)
## Function Actions
When given a function, calls it with `(query, opts)` matching the signature of
Ash code interface functions. The query is pre-filtered to the selected IDs:
BulkActionExecutor.execute(&MyApp.Users.archive/2,
resource: MyApp.User,
ids: ["1", "2"],
actor: current_user
)
## Options Handling
- **Atom actions**: `action_opts` are merged directly into Ash options
- **Function actions**: `action_opts` are wrapped in `bulk_options: [...]` for
code interface compatibility
"""
@type action :: atom() | (Ash.Query.t(), keyword() -> any())
@type opts :: [
resource: Ash.Resource.t(),
ids: [String.t()],
id_field: atom(),
actor: any(),
tenant: any(),
scope: any(),
action_opts: keyword()
]
@doc """
Executes a bulk action on the given IDs.
## Options
- `:resource` - The Ash resource (required)
- `:ids` - List of record IDs to act on (required)
- `:id_field` - The field to filter on (default: `:id`)
- `:actor` - Actor for authorization
- `:tenant` - Tenant for multi-tenancy
- `:scope` - Ash scope; when present, its actor/tenant are used unless
overridden by explicit `:actor`/`:tenant`, and its tracer/context/
authorize? options are merged into the Ash call
- `:action_opts` - Additional options for the action (e.g., `[return_records?: true]`)
## Action Types
- **Atom**: Introspects the resource to determine the action type. Calls
`Ash.bulk_update/4` for update actions or `Ash.bulk_destroy/4` for destroy
actions. Action opts are merged directly into the Ash options.
- **Function/2**: Calls the function with `(query, opts)` where query is
filtered to the selected IDs. Action opts are wrapped in `bulk_options: [...]`
for code interface compatibility.
## Examples
# Atom action - uses Ash.bulk_update
execute(:archive, resource: MyApp.User, ids: ["1", "2"], actor: current_user)
# With action options
execute(:archive, resource: MyApp.User, ids: ["1", "2"], action_opts: [return_records?: true])
# Function - receives filtered query
execute(&MyApp.Users.archive/2, resource: MyApp.User, ids: ["1", "2"])
# Destroy action
execute(:destroy, resource: MyApp.User, ids: ["1", "2"])
"""
@spec execute(action(), opts()) :: {:ok, any()} | {:error, any()}
def execute(action, opts) do
resource = Keyword.fetch!(opts, :resource)
ids = Keyword.fetch!(opts, :ids)
id_field = Keyword.get(opts, :id_field, :id)
action_opts = Keyword.get(opts, :action_opts, [])
query = build_query(resource, ids, id_field)
base_opts = build_auth_opts(opts)
run_action(action, query, base_opts, action_opts)
end
@doc """
Builds an Ash.Query filtered to the given IDs.
"""
@spec build_query(Ash.Resource.t(), [String.t()], atom()) :: Ash.Query.t()
def build_query(resource, ids, id_field \\ :id) do
filter = %{id_field => [in: ids]}
resource
|> Ash.Query.new()
|> Ash.Query.filter_input(filter)
end
@doc """
Normalizes a bulk action result to `{:ok, result}` or `{:error, reason}`.
"""
@spec normalize_result(any()) :: {:ok, any()} | {:error, any()}
def normalize_result(result) do
case result do
{:ok, _} = success -> success
{:error, _} = error -> error
:ok -> {:ok, :ok}
%Ash.BulkResult{status: :success} = bulk -> {:ok, bulk}
%Ash.BulkResult{status: :error, errors: errors} -> {:error, errors}
other -> {:ok, other}
end
end
# Private functions
# Auth opts handed to Ash. We pass `:scope`/`:actor`/`:tenant` straight
# through (nil-filtered) and let Ash apply its documented precedence
# (`deps/ash/lib/ash/scope.ex:43-51`).
#
# Nils are filtered because Cinder attrs use nil as "not supplied"; Ash's
# literal semantics would treat an explicit `actor: nil` as "erase scope's
# actor", which would punish the common case of `scope={@scope}` without
# an explicit `actor=`.
defp build_auth_opts(opts) do
[]
|> maybe_put(:scope, Keyword.get(opts, :scope))
|> maybe_put(:actor, Keyword.get(opts, :actor))
|> maybe_put(:tenant, Keyword.get(opts, :tenant))
end
defp maybe_put(opts, _key, nil), do: opts
defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
# Atom actions: merge action_opts directly (for Ash.bulk_update/bulk_destroy)
defp run_action(action, query, base_opts, action_opts) when is_atom(action) do
opts = Keyword.merge(base_opts, action_opts)
resource = query.resource
result =
case Ash.Resource.Info.action(resource, action) do
%{type: :destroy} ->
Ash.bulk_destroy(query, action, %{}, opts)
%{type: :update} ->
Ash.bulk_update(query, action, %{}, opts)
nil ->
{:error, "Action #{inspect(action)} not found on resource #{inspect(resource)}"}
%{type: type} ->
{:error, "Action #{inspect(action)} is a #{type} action, expected :update or :destroy"}
end
normalize_result(result)
end
# Function actions: wrap action_opts in bulk_options (for code interface)
defp run_action(action, query, base_opts, action_opts) when is_function(action, 2) do
opts =
if action_opts == [] do
base_opts
else
Keyword.put(base_opts, :bulk_options, action_opts)
end
try do
result = action.(query, opts)
normalize_result(result)
rescue
e -> {:error, Exception.message(e)}
end
end
defp run_action(_action, _query, _base_opts, _action_opts) do
{:error, "Invalid action - must be an atom or function/2"}
end
end