ash/test/actions/bulk/bulk_update_test.exs
2024-08-30 17:36:10 -04:00

1516 lines
45 KiB
Elixir

defmodule Ash.Test.Actions.BulkUpdateTest do
@moduledoc false
use ExUnit.Case, async: false
import ExUnit.CaptureLog
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
@impl true
def change(changeset, _, _) do
changeset
end
@impl true
def batch_change(changesets, _opts, _context) do
changesets
end
@impl true
def after_batch(results, _, _) do
Stream.map(results, fn {_changeset, result} ->
{:ok, %{result | title: result.title <> "_after"}}
end)
end
end
defmodule AddBeforeToTitle do
use Ash.Resource.Change
@impl true
def change(changeset, _, %{bulk?: true}) do
changeset
end
@impl true
def before_batch(changesets, _, _) do
Stream.map(changesets, fn changeset ->
title = Ash.Changeset.get_attribute(changeset, :title)
Ash.Changeset.force_change_attribute(changeset, :title, "before_" <> title)
end)
end
end
defmodule RecordBatchSizes do
use Ash.Resource.Change
@impl true
def batch_change(changesets, _, _) do
batch_size = length(changesets)
Stream.map(changesets, fn changeset ->
Ash.Changeset.force_change_attribute(changeset, :change_batch_size, batch_size)
end)
end
@impl true
def before_batch(changesets, _, _) do
batch_size = length(changesets)
Stream.map(changesets, fn changeset ->
Ash.Changeset.force_change_attribute(changeset, :before_batch_size, batch_size)
end)
end
@impl true
def after_batch(results, _, _) do
batch_size = length(results)
Stream.map(results, fn {_, result} ->
{:ok, %{result | after_batch_size: batch_size}}
end)
end
end
defmodule AtomicallyRequireActor do
use Ash.Resource.Change
def change(changeset, _, context) do
if context.actor do
changeset
else
Ash.Changeset.add_error(changeset, "actor is required")
end
end
def atomic(changeset, _, context) do
if context.actor do
{:atomic, changeset, %{}}
else
{:atomic, Ash.Changeset.add_error(changeset, "actor is required"), %{}}
end
end
end
defmodule ManualUpdate do
use Ash.Resource.ManualUpdate
def update(changeset, _opts, _context) do
{:ok, changeset.data}
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.BulkUpdateTest.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.BulkUpdateTest.Post,
primary_key?: true,
allow_nil?: false,
public?: true
belongs_to :destination_post, Ash.Test.Actions.BulkUpdateTest.Post,
primary_key?: true,
allow_nil?: false,
public?: true
end
end
defmodule AtomicOnlyValidation do
use Ash.Resource.Validation
@impl true
def atomic(_, _, _) do
:ok
end
end
defmodule Post do
@moduledoc false
use Ash.Resource,
domain: Domain,
data_layer: Ash.DataLayer.Ets,
notifiers: [Notifier],
authorizers: [Ash.Policy.Authorizer]
ets do
private? true
end
validations do
validate AtomicOnlyValidation,
on: [:update]
end
actions do
default_accept :*
defaults [:read, :destroy, create: :*, update: :*]
update :update_manual do
manual ManualUpdate
end
update :update_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
update :update_with_argument do
require_atomic? false
argument :a_title, :string do
allow_nil? false
end
change set_attribute(:title2, arg(:a_title))
end
update :update_with_before_transaction do
require_atomic? false
change before_transaction(fn changeset, _context ->
title = Ash.Changeset.get_attribute(changeset, :title)
Ash.Changeset.force_change_attribute(
changeset,
:title,
"before_transaction_" <> title
)
end)
end
update :update_with_after_action do
require_atomic? false
change after_action(fn _changeset, result, _context ->
{:ok, %{result | title: result.title <> "_stuff"}}
end)
end
update :update_with_after_batch do
require_atomic? false
change AddAfterToTitle
change AddBeforeToTitle
end
update :update_with_batch_sizes do
require_atomic? false
change RecordBatchSizes
end
update :update_with_after_transaction do
require_atomic? false
change after_transaction(fn
_changeset, {:ok, result}, _context ->
{:ok, %{result | title: result.title <> "_stuff"}}
_changeset, {:error, error}, _context ->
send(self(), {:error, error})
{:error, error}
end)
end
update :update_with_policy do
require_atomic? false
argument :authorize?, :boolean, allow_nil?: false
change set_context(%{authorize?: arg(:authorize?)})
change AtomicallyRequireActor
end
update :update_with_policy_without_requiring_actor do
argument :authorize?, :boolean, allow_nil?: false
change set_context(%{authorize?: arg(:authorize?)})
end
update :update_with_match do
validate match(:title4, ~r/^[a-z]+$/)
end
update :update_with_filter do
change filter(expr(title == "foo"))
end
end
identities do
identity :unique_title, :title do
pre_check_with Ash.Test.Actions.BulkUpdateTest.Domain
end
end
calculations do
calculate :hidden_calc, :string, expr("something") do
public?(true)
end
end
field_policies do
field_policy [:hidden_calc, :hidden_attribute] do
forbid_if always()
end
field_policy :* do
authorize_if always()
end
end
code_interface do
define :update_with_policy_without_requiring_actor
end
policies do
policy action(:update_with_policy) do
authorize_if context_equals(:authorize?, true)
end
policy action(:update_with_policy_without_requiring_actor) do
authorize_if context_equals(:authorize?, true)
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
attribute :title4, :string, public?: true
attribute :hidden_attribute, :string, public?: true
attribute :score, :integer, public?: true, constraints: [min: 0]
attribute :before_batch_size, :integer
attribute :after_batch_size, :integer
attribute :change_batch_size, :integer
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
defmodule MnesiaPost do
@doc false
use Ash.Resource, domain: Domain, data_layer: Ash.DataLayer.Mnesia, notifiers: [Notifier]
mnesia do
table :mnesia_post_updates
end
attributes do
uuid_primary_key :id
attribute :title, :string, allow_nil?: false, public?: true
attribute :title2, :string, public?: true
timestamps()
end
actions do
default_accept :*
defaults [:read, :destroy, :create, :update]
update :error_in_before_action_on_title2 do
atomic_upgrade? false
change before_action(fn changeset, context ->
if changeset.data.title == "title2" do
Ash.Changeset.add_error(changeset, "not good")
else
changeset
end
end)
end
update :update_with_after_action do
atomic_upgrade? false
change after_action(fn changeset, result, context ->
result
|> Ash.Changeset.for_update(:other_update, %{}, Ash.Context.to_opts(context))
|> Ash.update()
end)
end
update :other_update
end
end
defmodule Tenant do
@doc 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, writable?: true
end
defimpl Ash.ToTenant do
def to_tenant(tenant, _resource), do: tenant.id
end
end
defmodule MultitenantTagLink do
@moduledoc false
use Ash.Resource, domain: Domain, data_layer: Ash.DataLayer.Ets
ets do
private?(true)
end
multitenancy do
strategy :attribute
attribute :tenant_id
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 :tenant, Tenant, allow_nil?: false, primary_key?: true
belongs_to :source_tag, Ash.Test.Actions.BulkUpdateTest.MultitenantTag,
primary_key?: true,
allow_nil?: false,
public?: true
belongs_to :destination_tag, Ash.Test.Actions.BulkUpdateTest.MultitenantTag,
primary_key?: true,
allow_nil?: false,
public?: true
end
end
defmodule MultitenantTag do
@moduledoc false
use Ash.Resource,
domain: Domain,
data_layer: Ash.DataLayer.Ets
ets do
private? true
end
multitenancy do
strategy :attribute
attribute :tenant_id
end
actions do
default_accept :*
defaults [:read, :destroy, create: :*, update: :*]
update :add_related_tags do
require_atomic? false
argument :related_tags, {:array, :string} do
allow_nil? false
constraints min_length: 1, items: [min_length: 1]
end
change manage_relationship(:related_tags,
on_lookup: :relate,
on_no_match: :create,
value_is_key: :name,
use_identities: [:name]
)
end
end
attributes do
uuid_primary_key :id
attribute :name, :string, allow_nil?: false, public?: true
timestamps()
end
relationships do
belongs_to :tenant, Tenant, allow_nil?: false
many_to_many :related_tags, __MODULE__,
through: MultitenantTagLink,
source_attribute_on_join_resource: :source_tag_id,
destination_attribute_on_join_resource: :destination_tag_id,
public?: true
end
identities do
identity :name, [:name], pre_check_with: Domain
end
end
setup do
on_exit(fn ->
capture_log(fn ->
:mnesia.stop()
:mnesia.delete_schema([node()])
end)
end)
capture_log(fn ->
Ash.DataLayer.Mnesia.start(Domain, [MnesiaPost])
end)
:ok
end
test "returns updated records" do
assert %Ash.BulkResult{records: [%{title2: "updated value"}, %{title2: "updated value"}]} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update!(:update, %{title2: "updated value"},
resource: Post,
strategy: :atomic_batches,
return_records?: true,
return_errors?: true,
authorize?: false
)
end
test "sends notifications" do
assert %Ash.BulkResult{records: [%{title2: "updated value"}, %{title2: "updated value"}]} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update!(:update, %{title2: "updated value"},
resource: Post,
strategy: :atomic_batches,
return_records?: true,
notify?: true,
return_errors?: true,
authorize?: false
)
assert_received {:notification, %{data: %{title: "title2"}}}
assert_received {:notification, %{data: %{title: "title2"}}}
end
test "sends notifications with stream strategy in transactions" do
assert %Ash.BulkResult{records: [%{title2: "updated value"}, %{title2: "updated value"}]} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], MnesiaPost, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update!(:update, %{title2: "updated value"},
resource: MnesiaPost,
strategy: :stream,
allow_stream_with: :full_read,
return_records?: true,
notify?: true,
return_errors?: true,
authorize?: false
)
assert_received {:notification,
%{
data: %{title: "title1"},
changeset: %{attributes: %{title2: "updated value"}}
}}
assert_received {:notification,
%{
data: %{title: "title2"},
changeset: %{attributes: %{title2: "updated value"}}
}}
end
test "sends only the correct amount notifications with stream strategy in transactions, when run in a before action hook" do
MnesiaPost
|> Ash.Changeset.for_create(:create, %{title: "title_pre"})
|> Ash.Changeset.before_action(fn changeset ->
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], MnesiaPost, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update!(:update, %{title2: "updated value"},
resource: MnesiaPost,
strategy: :stream,
allow_stream_with: :full_read,
return_records?: true,
notify?: true,
return_errors?: true,
authorize?: false
)
changeset
end)
|> Ash.create!()
assert_received {:notification, %{changeset: %{action: %{type: :create}}}}
assert_received {:notification,
%{
data: %{title: "title1"},
changeset: %{attributes: %{title2: "updated value"}}
}}
assert_received {:notification,
%{
data: %{title: "title2"},
changeset: %{attributes: %{title2: "updated value"}}
}}
refute_received {:notification, _}
end
test "rolls back on errors in a `before_action` hook" do
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], MnesiaPost, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update(:error_in_before_action_on_title2, %{title2: "updated value"},
resource: MnesiaPost,
strategy: :stream,
allow_stream_with: :full_read,
return_records?: true,
notify?: true,
return_errors?: true,
authorize?: false
)
# the first mutation didn't happen
assert MnesiaPost |> Ash.read!() |> Enum.map(& &1.title) |> Enum.sort() == [
"title1",
"title2"
]
end
test "sends notifications with after action hooks in stream strategy in transactions" do
assert %Ash.BulkResult{records: [%{title2: "updated value"}, %{title2: "updated value"}]} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], MnesiaPost, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update!(:update_with_after_action, %{title2: "updated value"},
resource: MnesiaPost,
strategy: :stream,
allow_stream_with: :full_read,
return_records?: true,
notify?: true,
return_errors?: true,
authorize?: false
)
assert_received {:notification,
%{
data: %{title: "title1"},
changeset: %{attributes: %{title2: "updated value"}}
}}
assert_received {:notification,
%{
data: %{title: "title2"},
changeset: %{attributes: %{title2: "updated value"}}
}}
assert_received {:notification, %{changeset: %{action: %{name: :other_update}}}}
end
test "sends notifications with after action hooks with regular updates" do
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], MnesiaPost, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Enum.each(fn {:ok, result} ->
result
|> Ash.Changeset.for_update(:update_with_after_action, %{title2: "updated value"})
|> Ash.update!()
end)
assert_received {:notification,
%{
data: %{title: "title1"},
changeset: %{attributes: %{title2: "updated value"}}
}}
assert_received {:notification,
%{
data: %{title: "title2"},
changeset: %{attributes: %{title2: "updated value"}}
}}
assert_received {:notification, %{changeset: %{action: %{name: :other_update}}}}
end
test "notifications can be returned" do
assert %Ash.BulkResult{
records: [%{title2: "updated value"}, %{title2: "updated value"}],
notifications: [
%{data: %{title2: "updated value"}},
%{data: %{title2: "updated value"}}
]
} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update!(:update, %{title2: "updated value"},
resource: Post,
strategy: :atomic_batches,
return_records?: true,
return_notifications?: true,
return_errors?: true,
authorize?: false
)
end
test "runs changes" do
assert %Ash.BulkResult{
records: [
%{title: "title1_stuff", title2: "updated value"},
%{title: "title2_stuff", title2: "updated value"}
]
} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update!(:update_with_change, %{title2: "updated value"},
resource: Post,
strategy: :stream,
return_records?: true,
return_errors?: true,
authorize?: false
)
|> Map.update!(:records, fn records ->
Enum.sort_by(records, & &1.title)
end)
end
test "manual updates are supported" do
assert %Ash.BulkResult{
records: [
%{title: "title1"},
%{title: "title2"}
]
} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update!(:update_manual, %{},
resource: Post,
strategy: :stream,
return_records?: true,
return_errors?: true,
authorize?: false
)
|> Map.update!(:records, fn records ->
Enum.sort_by(records, & &1.title)
end)
end
test "accepts arguments" do
assert %Ash.BulkResult{
records: [
%{title: "title1", title2: "updated value"},
%{title: "title2", title2: "updated value"}
]
} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update!(:update_with_argument, %{a_title: "updated value"},
resource: Post,
return_records?: true,
return_errors?: true,
authorize?: false
)
|> Map.update!(:records, fn records ->
Enum.sort_by(records, & &1.title)
end)
end
test "runs after batch hooks" do
assert %Ash.BulkResult{
records: [
%{title: "before_title1_after", title2: "updated value"},
%{title: "before_title2_after", title2: "updated value"}
]
} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update!(:update_with_after_batch, %{title2: "updated value"},
resource: Post,
strategy: :stream,
return_records?: true,
return_errors?: true,
authorize?: false
)
|> Map.update!(:records, fn records ->
Enum.sort_by(records, & &1.title)
end)
end
test "runs changes in batches" do
create_records = fn count ->
Stream.iterate(1, &(&1 + 1))
|> Stream.map(fn i -> %{title: "title#{i}"} end)
|> Stream.take(count)
|> Ash.bulk_create!(Post, :create, return_stream?: true, return_records?: true)
|> Stream.map(fn {:ok, result} -> result end)
end
update_records = fn records, opts ->
opts = [resource: Post, return_records?: true, strategy: :stream] ++ opts
Ash.bulk_update!(records, :update_with_batch_sizes, %{}, opts)
end
batch_size_frequencies = fn %Ash.BulkResult{records: records} ->
records
|> Enum.map(&Map.take(&1, [:before_batch_size, :after_batch_size, :change_batch_size]))
|> Enum.frequencies()
end
assert create_records.(101)
|> update_records.([])
|> batch_size_frequencies.() == %{
%{change_batch_size: 100, before_batch_size: 100, after_batch_size: 100} => 100,
%{change_batch_size: 1, before_batch_size: 1, after_batch_size: 1} => 1
}
assert create_records.(10)
|> update_records.(batch_size: 3)
|> batch_size_frequencies.() == %{
%{change_batch_size: 3, before_batch_size: 3, after_batch_size: 3} => 9,
%{change_batch_size: 1, before_batch_size: 1, after_batch_size: 1} => 1
}
end
test "will return error count" do
assert %Ash.BulkResult{
error_count: 2
} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update(:update, %{title2: %{invalid: :value}},
resource: Post,
strategy: :stream,
return_records?: true,
authorize?: false
)
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,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update(:update, %{title2: %{invalid: :value}},
strategy: :stream,
resource: Post,
return_errors?: true,
authorize?: false
)
end
test "runs match validation with atomic_batches strategy" do
assert %Ash.BulkResult{
error_count: 1
} =
Ash.bulk_create!([%{title: "title1"}], Post, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update(:update_with_match, %{title4: "INVALID"},
strategy: :atomic,
resource: Post,
return_errors?: true,
authorize?: false
)
end
test "runs before transaction hooks" do
assert %Ash.BulkResult{
records: [
%{title: "before_transaction_title1", title2: "updated value"},
%{title: "before_transaction_title2", title2: "updated value"}
]
} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update!(:update_with_before_transaction, %{title2: "updated value"},
resource: Post,
strategy: :stream,
return_records?: true,
return_errors?: true,
authorize?: false
)
|> Map.update!(:records, fn records ->
Enum.sort_by(records, & &1.title)
end)
end
test "runs after action hooks" do
assert %Ash.BulkResult{
records: [
%{title: "title1_stuff", title2: "updated value"},
%{title: "title2_stuff", title2: "updated value"}
]
} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update!(:update_with_after_action, %{title2: "updated value"},
resource: Post,
strategy: :stream,
return_records?: true,
return_errors?: true,
authorize?: false
)
|> Map.update!(:records, fn records ->
Enum.sort_by(records, & &1.title)
end)
end
test "runs after transaction hooks on success" do
assert %Ash.BulkResult{
records: [
%{title: "title1_stuff", title2: "updated value"},
%{title: "title2_stuff", title2: "updated value"}
]
} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update!(:update_with_after_transaction, %{title2: "updated value"},
resource: Post,
strategy: :stream,
return_records?: true,
return_errors?: true,
authorize?: false
)
|> Map.update!(:records, fn records ->
Enum.sort_by(records, & &1.title)
end)
end
test "runs after transaction hooks on failure" do
assert %Ash.BulkResult{error_count: 2} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update(:update_with_after_transaction, %{title2: 3},
resource: Post,
strategy: :stream,
return_records?: true,
return_errors?: true,
authorize?: false
)
assert_receive {:error, _}
assert_receive {:error, _}
end
test "works with empty list without the need to define a domain" do
assert %Ash.BulkResult{records: []} =
Ash.bulk_update!([], :update, %{title2: 3}, return_records?: true)
end
test "handles numeric constraints correctly" do
assert %Ash.BulkResult{
records: [%{score: 3}, %{score: 3}]
} =
Ash.bulk_create!(
[%{title: "title1", score: 1}, %{title: "title2", score: 2}],
Post,
:create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update!(:update, %{score: 3},
resource: Post,
strategy: :atomic,
return_records?: true,
return_errors?: true,
authorize?: false
)
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,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update(
:update_with_policy,
%{title2: "updated value", authorize?: true},
authorize?: true,
actor: %{foo: :bar},
resource: Post,
return_records?: true,
return_errors?: true
)
end
test "field authorization is run" do
assert %Ash.BulkResult{
records: [
%{
hidden_attribute: %Ash.ForbiddenField{},
hidden_calc: %Ash.ForbiddenField{}
},
%{
hidden_attribute: %Ash.ForbiddenField{},
hidden_calc: %Ash.ForbiddenField{}
}
],
errors: []
} =
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update(
:update_with_policy,
%{title2: "updated value", authorize?: true},
authorize?: true,
actor: %{foo: :bar},
resource: Post,
return_records?: true,
return_errors?: true,
load: [:hidden_calc]
)
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,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update(
:update_with_policy,
%{title2: "updated value", authorize?: false},
strategy: :atomic,
authorize?: true,
actor: %{foo: :bar},
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,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update(
:update_with_policy,
%{title2: "updated value", authorize?: false},
strategy: :stream,
actor: %{foo: :bar},
authorize?: true,
resource: Post,
return_records?: true,
return_errors?: true
)
end
test "policy failure results in failures when using code interface" do
Ash.bulk_create!([%{title: "title1"}, %{title: "title2"}], Post, :create,
return_stream?: true,
return_records?: true
)
|> Enum.each(fn {:ok, post} ->
assert_raise Ash.Error.Forbidden, fn ->
Post.update_with_policy_without_requiring_actor!(post.id, %{
title2: "updated value",
authorize?: false
})
end
end)
end
test "respects filter when using atomic" do
assert %Ash.BulkResult{
records: [
%{title: "foo", title2: "updated value"}
]
} =
Ash.bulk_create!([%{title: "foo"}, %{title: "bar"}], Post, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update!(:update_with_filter, %{title2: "updated value"},
resource: Post,
strategy: :atomic_batches,
return_records?: true,
return_errors?: true,
authorize?: false
)
end
end
test "respects filter when using stream" do
assert %Ash.BulkResult{
records: [
%{title: "foo", title2: "updated value"}
]
} =
Ash.bulk_create!([%{title: "foo"}, %{title: "bar"}], Post, :create,
return_stream?: true,
return_records?: true,
authorize?: false
)
|> Stream.map(fn {:ok, result} ->
result
end)
|> Ash.bulk_update!(:update_with_filter, %{title2: "updated value"},
resource: Post,
strategy: :stream,
return_records?: true,
return_errors?: true,
authorize?: false
)
end
describe "load" do
test "allows loading has_many relationship" do
author =
Author
|> Ash.Changeset.for_create(:create, %{name: "Name"})
|> Ash.create!()
for n <- [2, 1] do
Post
|> Ash.Changeset.for_create(:create, %{title: "Post #{n}"})
|> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove)
|> Ash.create!()
end
load_query =
Post
|> Ash.Query.sort(title: :asc)
|> Ash.Query.select([:title])
assert %Ash.BulkResult{records: [author]} =
Ash.bulk_update!([author], :update, %{name: "Updated Name"},
resource: Author,
strategy: :atomic_batches,
return_records?: true,
return_errors?: true,
authorize?: false,
load: [posts: load_query]
)
assert [%Post{title: "Post 1"}, %Post{title: "Post 2"}] = author.posts
end
test "allows loading paginated has_many relationship" do
author =
Author
|> Ash.Changeset.for_create(:create, %{name: "Name"})
|> Ash.create!()
for n <- [2, 1] do
Post
|> Ash.Changeset.for_create(:create, %{title: "Post #{n}"})
|> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove)
|> Ash.create!()
end
offset_pagination_query =
Post
|> Ash.Query.sort(title: :asc)
|> Ash.Query.select([:title])
|> Ash.Query.page(count: true, limit: 1)
assert %Ash.BulkResult{records: [author]} =
Ash.bulk_update!([author], :update, %{name: "Updated Name 1"},
resource: Author,
strategy: :atomic_batches,
return_records?: true,
return_errors?: true,
authorize?: false,
load: [posts: offset_pagination_query]
)
assert %Ash.Page.Keyset{
results: [%Post{title: "Post 1", __metadata__: %{keyset: keyset}}],
limit: 1,
count: 2,
more?: true
} = author.posts
keyset_pagination_query =
Post
|> Ash.Query.sort(title: :asc)
|> Ash.Query.select([:title])
|> Ash.Query.page(count: true, limit: 1, after: keyset)
assert %Ash.BulkResult{records: [author]} =
Ash.bulk_update!([author], :update, %{name: "Updated Name 2"},
resource: Author,
strategy: :atomic_batches,
return_records?: true,
return_errors?: true,
authorize?: false,
load: [posts: keyset_pagination_query]
)
assert %Ash.Page.Keyset{
results: [%Post{title: "Post 2"}],
limit: 1,
count: 2,
more?: false,
before: nil,
after: ^keyset
} = 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"})
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!()
load_query =
Post
|> Ash.Query.sort(title: :asc)
|> Ash.Query.select([:title])
assert %Ash.BulkResult{records: [post]} =
Ash.bulk_update!([post], :update, %{title: "Updated Title"},
resource: Post,
strategy: :atomic_batches,
return_records?: true,
return_errors?: true,
authorize?: false,
load: [related_posts: load_query]
)
assert [%Post{title: "Related 1"}, %Post{title: "Related 2"}] = post.related_posts
end
test "allows loading paginated many_to_many relationship" do
related_post1 = Ash.create!(Post, %{title: "Related 1"})
related_post2 = Ash.create!(Post, %{title: "Related 2"})
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!()
offset_pagination_query =
Post
|> Ash.Query.sort(title: :asc)
|> Ash.Query.select([:title])
|> Ash.Query.page(count: true, limit: 1)
assert %Ash.BulkResult{records: [post]} =
Ash.bulk_update!([post], :update, %{title: "Updated Title 1"},
resource: Post,
strategy: :atomic_batches,
return_records?: true,
return_errors?: true,
authorize?: false,
load: [related_posts: offset_pagination_query]
)
assert %Ash.Page.Keyset{
results: [%Post{title: "Related 1", __metadata__: %{keyset: keyset}}],
limit: 1,
count: 2,
more?: true
} = post.related_posts
keyset_pagination_query =
Post
|> Ash.Query.sort(title: :asc)
|> Ash.Query.select([:title])
|> Ash.Query.page(count: true, limit: 1, after: keyset)
assert %Ash.BulkResult{records: [post]} =
Ash.bulk_update!([post], :update, %{title: "Updated Title 2"},
resource: Post,
strategy: :atomic_batches,
return_records?: true,
return_errors?: true,
authorize?: false,
load: [related_posts: keyset_pagination_query]
)
assert %Ash.Page.Keyset{
results: [%Post{title: "Related 2"}],
limit: 1,
count: 2,
more?: false,
before: nil,
after: ^keyset
} = post.related_posts
end
test "allows loading paginated many_to_many relationship for multitenant resources" do
tenant = Ash.create!(Tenant, %{})
tag = Ash.create!(MultitenantTag, %{name: "tag"}, tenant: tenant)
_ = Ash.create!(MultitenantTag, %{name: "existing"}, tenant: tenant)
offset_pagination_query =
MultitenantTag
|> Ash.Query.sort(name: :asc)
|> Ash.Query.select([:name])
|> Ash.Query.page(count: true, limit: 1)
assert %Ash.BulkResult{records: [tag]} =
Ash.bulk_update!([tag], :add_related_tags, %{related_tags: ["existing"]},
resource: MultitenantTag,
strategy: :stream,
tenant: tenant,
return_records?: true,
return_errors?: true,
authorize?: false,
load: [related_tags: offset_pagination_query]
)
assert %Ash.Page.Keyset{
results: [%MultitenantTag{name: "existing", __metadata__: %{keyset: keyset}}],
limit: 1,
count: 1,
more?: false
} = tag.related_tags
keyset_pagination_query =
MultitenantTag
|> Ash.Query.sort(name: :asc)
|> Ash.Query.select([:name])
|> Ash.Query.page(count: true, limit: 1, after: keyset)
assert %Ash.BulkResult{records: [tag]} =
Ash.bulk_update!([tag], :add_related_tags, %{related_tags: ["new"]},
resource: MultitenantTag,
tenant: tenant,
strategy: :stream,
return_records?: true,
return_errors?: true,
authorize?: false,
load: [related_tags: keyset_pagination_query]
)
assert %Ash.Page.Keyset{
results: [%MultitenantTag{name: "new"}],
limit: 1,
count: 2,
more?: false,
before: nil,
after: ^keyset
} = tag.related_tags
end
end
end