ash/test/actions/bulk/bulk_destroy_test.exs
Davide Briani cd06f919c0
fix: load relationships on bulk operations (#1234)
This change validates that the `load` statement of bulk operations is
respected when specified, and correctly loads relationships.

Loading relationships with pagination on results for bulk destroys is
still not supported. Indeed, relationships are currently queried using a
lateral join but after the resource deletion has happened, so it looks
like nothing is related.

Signed-off-by: Davide Briani <davide@briani.dev>
2024-06-10 20:14:37 -04:00

710 lines
20 KiB
Elixir

defmodule Ash.Test.Actions.BulkDestroyTest do
@moduledoc false
use ExUnit.Case, async: true
require Ash.Query
alias Ash.Test.Domain, as: Domain
defmodule Notifier do
use Ash.Notifier
def notify(notification) do
send(self(), {:notification, notification})
end
end
defmodule AddAfterToTitle do
use Ash.Resource.Change
def change(changeset, _, _) do
changeset
end
def atomic(_, _, _), do: :ok
def batch_change(changesets, _, _) do
changesets
end
def after_batch(results, _, _) do
Stream.map(results, fn {_changeset, result} ->
{:ok, %{result | title: result.title <> "_after"}}
end)
end
end
defmodule Author do
@moduledoc false
use Ash.Resource, domain: Domain, data_layer: Ash.DataLayer.Ets
ets do
private?(true)
end
actions do
default_accept :*
defaults [:read, :create, :update, :destroy]
end
attributes do
uuid_primary_key :id
attribute :name, :string do
public?(true)
end
end
relationships do
has_many :posts, Ash.Test.Actions.BulkDestroyTest.Post,
destination_attribute: :author_id,
public?: true
end
end
defmodule PostLink do
@moduledoc false
use Ash.Resource, domain: Domain, data_layer: Ash.DataLayer.Ets
ets do
private?(true)
end
attributes do
attribute :type, :string do
public?(true)
end
end
actions do
default_accept :*
defaults [:read, :destroy, create: :*, update: :*]
end
relationships do
belongs_to :source_post, Ash.Test.Actions.BulkDestroyTest.Post,
primary_key?: true,
allow_nil?: false,
public?: true
belongs_to :destination_post, Ash.Test.Actions.BulkDestroyTest.Post,
primary_key?: true,
allow_nil?: false,
public?: true
end
end
defmodule Post do
@moduledoc false
use Ash.Resource,
domain: Domain,
notifiers: [Notifier],
data_layer: Ash.DataLayer.Ets,
authorizers: [Ash.Policy.Authorizer]
ets do
private? true
end
actions do
default_accept :*
defaults [:destroy, create: :*, update: :*]
read :read do
primary? true
pagination keyset?: true, required?: false
end
destroy :destroy_with_change do
require_atomic? false
change fn changeset, _ ->
title = Ash.Changeset.get_attribute(changeset, :title)
Ash.Changeset.force_change_attribute(changeset, :title, title <> "_stuff")
end
end
destroy :destroy_with_argument do
require_atomic? false
argument :a_title, :string do
allow_nil? false
end
end
destroy :destroy_with_after_action do
require_atomic? false
change after_action(fn _changeset, result, _context ->
{:ok, %{result | title: result.title <> "_stuff"}}
end)
end
destroy :destroy_with_after_batch do
require_atomic? false
change AddAfterToTitle
end
destroy :destroy_with_after_transaction do
require_atomic? false
argument :a_title, :string
change after_transaction(fn
_changeset, {:ok, result}, _context ->
{:ok, %{result | title: result.title <> "_stuff"}}
_changeset, {:error, error}, _context ->
send(self(), {:error, error})
end)
end
destroy :destroy_with_policy do
require_atomic? false
argument :authorize?, :boolean, allow_nil?: false
change set_context(%{authorize?: arg(:authorize?)})
end
destroy :destroy_with_filter do
change filter(expr(title == "foo"))
end
destroy :soft do
require_atomic? false
soft? true
change set_attribute(:title2, "archived")
end
destroy :forbidden_destroy do
end
end
identities do
identity :unique_title, :title do
pre_check_with Ash.Test.Actions.BulkUpdateTest.Domain
end
end
policies do
policy action(:forbidden_destroy) do
authorize_if never()
end
policy action(:destroy_with_policy) do
authorize_if context_equals(:authorize?, true)
end
policy action(:read) do
authorize_if always()
end
policy always() do
authorize_if always()
end
end
attributes do
uuid_primary_key :id
attribute :title, :string, allow_nil?: false, public?: true
attribute :title2, :string, public?: true
attribute :title3, :string, public?: true
timestamps()
end
relationships do
belongs_to :author, Author, public?: true
many_to_many :related_posts, __MODULE__,
through: PostLink,
source_attribute_on_join_resource: :source_post_id,
destination_attribute_on_join_resource: :destination_post_id,
public?: true
end
end
test "returns destroyed records" do
assert %Ash.BulkResult{records: [%{}, %{}]} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_destroy!(:destroy, %{},
resource: Post,
strategy: :stream,
return_records?: true,
return_errors?: true
)
assert [] = Ash.read!(Post)
end
test "sends notifications" do
assert %Ash.BulkResult{records: [%{}, %{}]} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_destroy!(:destroy, %{},
resource: Post,
strategy: :stream,
notify?: true,
return_records?: true,
return_errors?: true
)
assert_received {:notification, %{data: %{title: "title1"}}}
assert_received {:notification, %{data: %{title: "title2"}}}
end
test "doesn't send notifications if not asked to" do
assert %Ash.BulkResult{records: [%{}, %{}]} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_destroy!(:destroy, %{},
resource: Post,
strategy: :stream,
return_records?: true,
return_errors?: true
)
refute_received {:notification, _}
end
test "notifications can be returned" do
assert %Ash.BulkResult{records: [%{}, %{}], notifications: [%{}, %{}]} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_destroy!(:destroy, %{},
resource: Post,
strategy: :stream,
return_notifications?: true,
return_records?: true,
return_errors?: true
)
end
test "runs changes" do
assert %Ash.BulkResult{
records: [
%{title: "title1_stuff"},
%{title: "title2_stuff"}
]
} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_destroy!(:destroy_with_change, %{},
resource: Post,
strategy: [:stream],
return_records?: true,
return_errors?: true
)
|> Map.update!(:records, fn records ->
Enum.sort_by(records, & &1.title)
end)
assert [] = Ash.read!(Post)
end
test "accepts arguments" do
assert %Ash.BulkResult{
records: [
%{title: "title1", title2: nil},
%{title: "title2", title2: nil}
]
} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_destroy!(:destroy_with_argument, %{a_title: "a value"},
resource: Post,
strategy: [:stream],
return_records?: true,
return_errors?: true
)
|> Map.update!(:records, fn records ->
Enum.sort_by(records, & &1.title)
end)
assert [] = Ash.read!(Post)
end
test "runs after batch hooks" do
assert %Ash.BulkResult{
records: [
%{title: "title1_after"},
%{title: "title2_after"}
]
} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_destroy!(:destroy_with_after_batch, %{},
resource: Post,
strategy: [:atomic],
return_records?: true,
return_errors?: true
)
|> Map.update!(:records, fn records ->
Enum.sort_by(records, & &1.title)
end)
assert [] = Ash.read!(Post)
end
test "will return errors on request" do
assert %Ash.BulkResult{
error_count: 1,
errors: [%Ash.Error.Invalid{}]
} =
Ash.bulk_create!([%{title: "title1"}], Post, :create,
return_stream?: true,
return_records?: true
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_destroy(:destroy_with_argument, %{a_title: %{invalid: :value}},
resource: Post,
strategy: :stream,
return_errors?: true
)
assert [_] = Ash.read!(Post)
end
test "runs after action hooks" do
assert %Ash.BulkResult{
records: [
%{title: "title1_stuff"},
%{title: "title2_stuff"}
]
} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_destroy!(:destroy_with_after_action, %{},
resource: Post,
strategy: :stream,
return_records?: true,
return_errors?: true
)
|> Map.update!(:records, fn records ->
Enum.sort_by(records, & &1.title)
end)
assert [] = Ash.read!(Post)
end
test "runs after transaction hooks on success" do
assert %Ash.BulkResult{
records: [
%{title: "title1_stuff"},
%{title: "title2_stuff"}
]
} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_destroy!(:destroy_with_after_transaction, %{},
strategy: :stream,
resource: Post,
return_records?: true,
return_errors?: true
)
|> Map.update!(:records, fn records ->
Enum.sort_by(records, & &1.title)
end)
assert [] = Ash.read!(Post)
end
test "runs after transaction hooks on failure" do
assert %Ash.BulkResult{
error_count: 1,
errors: [%Ash.Error.Invalid{}]
} =
Ash.bulk_create!([%{title: "title1"}], Post, :create,
return_stream?: true,
return_records?: true
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_destroy(:destroy_with_after_transaction, %{a_title: %{invalid: :value}},
resource: Post,
strategy: :stream,
return_errors?: true
)
assert_receive {:error, _error}
end
test "soft destroys" do
assert %Ash.BulkResult{
records: [
%{title2: "archived"},
%{title2: "archived"}
]
} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_destroy!(:soft, %{},
strategy: [:stream],
resource: Post,
return_records?: true,
return_errors?: true
)
|> Map.update!(:records, fn records ->
Enum.sort_by(records, & &1.title)
end)
end
test "works with empty list without the need to define a domain" do
assert %Ash.BulkResult{records: []} =
Ash.bulk_destroy!([], :destroy, %{}, return_records?: true)
end
describe "authorization" do
test "policy success results in successes" do
assert %Ash.BulkResult{records: [_, _], errors: []} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_destroy(
:destroy_with_policy,
%{authorize?: true},
strategy: [:atomic_batches],
authorize?: true,
resource: Post,
return_records?: true,
return_errors?: true
)
end
test "policy success results in successes with query" do
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_records?: true
)
assert %Ash.BulkResult{errors: []} =
Post
|> Ash.Query.filter(title: [in: ["title1", "title2"]])
|> Ash.bulk_destroy(
:destroy_with_policy,
%{authorize?: true},
strategy: :stream,
authorize?: true,
return_errors?: true
)
end
test "policy failure results in failures" do
assert %Ash.BulkResult{errors: [%Ash.Error.Forbidden{}], records: []} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_destroy(
:destroy_with_policy,
%{authorize?: false},
authorize?: true,
strategy: :atomic,
resource: Post,
return_records?: true,
return_errors?: true
)
assert %Ash.BulkResult{
errors: [%Ash.Error.Forbidden{}, %Ash.Error.Forbidden{}],
records: []
} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_destroy(
:destroy_with_policy,
%{authorize?: false},
authorize?: true,
strategy: :stream,
resource: Post,
return_records?: true,
return_errors?: true
)
end
end
test "respects filter with atomics" do
assert %Ash.BulkResult{records: [%{title: "foo"}]} =
Ash.bulk_create!([%{title: "foo"}, %{title: "bar"}], Post, :create,
return_stream?: true,
return_records?: true
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_destroy!(:destroy_with_filter, %{},
resource: Post,
strategy: :atomic_batches,
return_records?: true,
return_errors?: true
)
assert [%{title: "bar"}] = Ash.read!(Post)
end
test "respects filter with stream" do
assert %Ash.BulkResult{records: [%{title: "foo"}]} =
Ash.bulk_create!([%{title: "foo"}, %{title: "bar"}], Post, :create,
return_stream?: true,
return_records?: true
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_destroy!(:destroy_with_filter, %{},
resource: Post,
strategy: :stream,
return_records?: true,
return_errors?: true
)
assert [%{title: "bar"}] = Ash.read!(Post)
end
test "validates the passed-in action" do
bulk_result =
[%{title: "title1"}, %{title: "title2"}]
|> Ash.bulk_create!(Post, :create, return_records?: true)
|> Map.get(:records)
|> Ash.bulk_destroy(:this_is_not_an_actual_destroy_action, %{}, return_errors?: true)
assert bulk_result.status == :error
assert [] != bulk_result.errors
assert [_, _] = Ash.read!(Post)
end
test "skipping authorization checks is honoured" do
posts =
Ash.bulk_create!([%{title: "delete me"}], Post, :create, return_records?: true)
|> Map.get(:records)
result =
%Ash.BulkResult{} = Ash.bulk_destroy(posts, :forbidden_destroy, %{}, authorize?: false)
assert result.status == :success
assert result.error_count == 0
assert [] = Ash.read!(Post)
end
describe "load" do
test "allows loading has_many relationship" do
post1 = Ash.create!(Post, %{title: "Post 1"})
post2 = Ash.create!(Post, %{title: "Post 2"})
load_query =
Post
|> Ash.Query.sort(title: :asc)
|> Ash.Query.select([:title])
assert %Ash.BulkResult{records: [author]} =
Author
|> Ash.Changeset.for_create(:create, %{name: "Author"})
|> Ash.Changeset.manage_relationship(:posts, [post2, post1],
type: :append_and_remove
)
|> Ash.create!()
|> List.wrap()
|> Ash.bulk_destroy!(:destroy, %{},
resource: Author,
return_records?: true,
load: [posts: load_query]
)
assert [%Post{title: "Post 1"}, %Post{title: "Post 2"}] = author.posts
end
test "allows loading many_to_many relationship" do
related_post1 = Ash.create!(Post, %{title: "Related 1"})
related_post2 = Ash.create!(Post, %{title: "Related 2"})
load_query =
Post
|> Ash.Query.sort(title: :asc)
|> Ash.Query.select([:title])
assert %Ash.BulkResult{records: [post]} =
Post
|> Ash.Changeset.for_create(:create, %{title: "Title"})
|> Ash.Changeset.manage_relationship(
:related_posts,
[related_post2, related_post1],
type: :append_and_remove
)
|> Ash.create!()
|> List.wrap()
|> Ash.bulk_destroy!(:destroy, %{},
resource: Post,
return_records?: true,
load: [related_posts: load_query]
)
assert [%Post{title: "Related 1"}, %Post{title: "Related 2"}] = post.related_posts
end
end
end