ash/test/embedded_resource_test.exs

589 lines
14 KiB
Elixir

defmodule Ash.Test.Changeset.EmbeddedResourceTest do
@moduledoc false
use ExUnit.Case, async: true
alias Ash.Changeset
require Ash.Query
defmodule Increasing do
use Ash.Resource.Validation
def init(opts), do: {:ok, opts}
def validate(changeset, opts, _) do
field = Keyword.get(opts, :field)
if Changeset.changing_attribute?(changeset, field) do
if Map.get(changeset.data, field) >=
Changeset.get_attribute(changeset, field) do
{:error, message: "must be increasing", field: field}
else
:ok
end
else
:ok
end
end
end
defmodule DestroyMe do
use Ash.Resource.Validation
def init(opts), do: {:ok, opts}
def validate(changeset, _opts, _) do
if Changeset.get_attribute(changeset, :first_name) == "destroy" &&
Changeset.get_attribute(changeset, :last_name) == "me" do
:ok
else
{:error, "must be named \"destroy me\" to remove a profile"}
end
end
end
defmodule ProfileWithId do
use Ash.Resource, data_layer: :embedded
attributes do
uuid_primary_key :id, writable?: true
attribute :first_name, :string do
public?(true)
end
attribute :last_name, :string do
public?(true)
end
attribute :counter, :integer, default: 0, allow_nil?: false, public?: true
end
validations do
validate present([:first_name, :last_name])
validate {Increasing, field: :counter}, on: :update
validate {DestroyMe, []}, on: :destroy
end
calculations do
calculate :full_name, :string, concat([:first_name, :last_name], " ") do
public? true
end
end
end
defmodule Profile do
use Ash.Resource, data_layer: :embedded
attributes do
attribute :first_name, :string do
public?(true)
end
attribute :last_name, :string do
public?(true)
end
attribute :counter, :integer, default: 0, allow_nil?: false, public?: true
end
validations do
validate present([:first_name, :last_name])
validate {Increasing, field: :counter}, on: :update
validate {DestroyMe, []}, on: :destroy
end
calculations do
calculate :full_name, :string, concat([:first_name, :last_name], " ") do
public? true
end
end
end
defmodule Tag do
use Ash.Resource, data_layer: :embedded
attributes do
attribute :name, :string do
public?(true)
end
attribute :score, :integer do
public?(true)
end
attribute :key, :string do
source :different_key
end
end
validations do
# You can't remove a tag unless you first set its score to 0
validate absent(:score), on: :destroy
validate {Increasing, field: :score}, on: :update
validate present(:score), on: :create
end
end
defmodule TagWithNoNils do
use Ash.Resource, data_layer: :embedded, embed_nil_values?: false
attributes do
attribute :name, :string do
public?(true)
end
attribute :score, :integer do
public?(true)
end
end
validations do
# You can't remove a tag unless you first set its score to 0
validate absent(:score), on: :destroy
validate {Increasing, field: :score}, on: :update
validate present(:score), on: :create
end
end
defmodule TagWithId do
use Ash.Resource, data_layer: :embedded
attributes do
uuid_primary_key :id, writable?: true
attribute :type, :string do
public?(true)
end
attribute :name, :string do
public?(true)
end
attribute :score, :integer do
public?(true)
end
end
validations do
# You can't remove a tag unless you first set its score to 0
validate absent(:score), on: :destroy
validate {Increasing, field: :score}, on: :update
validate present(:score), on: :create
end
end
defmodule UnionTagWithId do
use Ash.Type.NewType,
subtype_of: :union,
constraints: [
types: [
tag: [
type: TagWithId,
tag: :type,
tag_value: :tag_with_id
]
]
]
end
defmodule Author do
use Ash.Resource,
domain: Ash.Test.Changeset.EmbeddedResourceTest.Domain,
data_layer: Ash.DataLayer.Ets
ets do
private?(true)
end
actions do
default_accept :*
create :create
update :update do
require_atomic? false
end
end
attributes do
uuid_primary_key :id, writable?: true
attribute :profile, Profile,
constraints: [
load: [:full_name]
],
public?: true
attribute :profile_with_id, ProfileWithId,
constraints: [
load: [:full_name]
],
public?: true
attribute :tags, {:array, Tag} do
public?(true)
end
attribute :tags_max_length, {:array, Tag} do
public?(true)
constraints max_length: 2, min_length: 1
end
attribute :tags_with_id, {:array, TagWithId} do
public?(true)
end
attribute :union_tags_with_id, {:array, UnionTagWithId} do
public?(true)
end
end
end
defmodule Domain do
@moduledoc false
use Ash.Domain
resources do
resource Author
end
end
test "embedded resources can be created" do
assert %{profile: %Profile{}, tags: [%Tag{name: "trainer"}, %Tag{name: "human"}]} =
Changeset.for_create(
Author,
:create,
%{
profile: %{
first_name: "ash",
last_name: "ketchum"
},
tags: [
%{name: "trainer", score: 10},
%{name: "human", score: 100}
]
}
)
|> Ash.create!()
end
test "embedded resources honor the `source` of an attribute" do
assert {:ok, %{different_key: "foo"}} = Ash.Type.dump_to_native(Tag, %Tag{key: "foo"}, [])
end
test "embed_nil_values?: false causes nil values not to be dumped" do
value = %TagWithNoNils{name: "foo", score: nil}
assert {:ok, dumped} = Ash.Type.dump_to_embedded(TagWithNoNils, value, [])
assert Map.keys(dumped) == [:name]
end
test "embedded resources can be constrained with min/max length" do
assert_raise Ash.Error.Invalid, ~r/must have 2 or fewer items/, fn ->
Changeset.for_create(
Author,
:create,
%{
profile: %{
first_name: "ash",
last_name: "ketchum"
},
tags_max_length: [
%{name: "trainer", score: 10},
%{name: "human", score: 100},
%{name: "gym_leader", score: 150}
]
}
)
|> Ash.create!()
end
assert_raise Ash.Error.Invalid, ~r/must have 1 or more items/, fn ->
Changeset.for_create(
Author,
:create,
%{
profile: %{
first_name: "ash",
last_name: "ketchum"
},
tags_max_length: []
}
)
|> Ash.create!()
end
end
test "embedded resources support calculations" do
assert %{profile: %Profile{full_name: "ash ketchum"}} =
Changeset.for_create(
Author,
:create,
%{
profile: %{
first_name: "ash",
last_name: "ketchum"
}
}
)
|> Ash.create!()
end
test "embedded resources run validations on create" do
msg =
~r/Invalid value provided for last_name: exactly 2 of "first_name,last_name" must be present/
assert_raise Ash.Error.Invalid,
msg,
fn ->
Author
|> Changeset.for_create(
:create,
%{
profile: %{
first_name: "ash"
}
}
)
|> Ash.create!()
end
end
test "embedded resources run validations on update" do
assert author =
Changeset.for_create(
Author,
:create,
%{
profile: %{
first_name: "ash",
last_name: "ketchum"
}
}
)
|> Ash.create!()
input = %{counter: author.profile.counter - 1}
assert_raise Ash.Error.Invalid,
~r/Invalid value provided for counter: must be increasing/,
fn ->
Changeset.for_update(
author,
:update,
%{
profile: input
}
)
|> Ash.update!()
end
end
test "embedded resources run validations on destroy" do
assert author =
Changeset.for_create(
Author,
:create,
%{
profile: %{
first_name: "ash",
last_name: "ketchum"
}
}
)
|> Ash.create!()
assert_raise Ash.Error.Invalid, ~r/must be named "destroy me" to remove a profile/, fn ->
Changeset.for_update(
author,
:update,
%{profile: nil}
)
|> Ash.update!()
end
author =
Changeset.for_update(
author,
:update,
%{profile: %{first_name: "destroy", last_name: "me"}}
)
|> Ash.update!()
Changeset.for_update(
author,
:update,
%{profile: nil}
)
|> Ash.update!()
end
test "when a non-array embedded resource has a public primary key, changes are considered a destroy + create, not an update" do
assert author =
Changeset.for_create(
Author,
:create,
%{
profile_with_id: %{
first_name: "ash",
last_name: "ketchum"
}
}
)
|> Ash.create!()
assert_raise Ash.Error.Invalid, ~r/must be named "destroy me" to remove a profile/, fn ->
Changeset.for_update(
author,
:update,
%{profile_with_id: %{first_name: "foo", last_name: "bar"}}
)
|> Ash.update!()
end
author =
Changeset.for_update(
author,
:update,
%{
profile_with_id: %{
id: author.profile_with_id.id,
first_name: "destroy",
last_name: "me"
}
}
)
|> Ash.update!()
Changeset.for_update(
author,
:update,
%{profile_with_id: %{first_name: "foo", last_name: "bar"}}
)
|> Ash.update!()
end
test "a list of embeds without an id are destroyed and created each time" do
assert author =
Changeset.for_create(
Author,
:create,
%{
tags: [
%{name: "trainer", score: 10},
%{name: "human", score: 100}
]
}
)
|> Ash.create!()
assert_raise Ash.Error.Invalid,
~r/Invalid value provided for score: must be present/,
fn ->
Changeset.for_update(
author,
:update,
%{
tags: [
%{name: "pokemon"}
]
}
)
|> Ash.update!()
end
assert_raise Ash.Error.Invalid,
~r/Invalid value provided for score: must be absent/,
fn ->
Changeset.for_update(
author,
:update,
%{
tags: [
%{name: "pokemon", score: 1}
]
}
)
|> Ash.update!()
end
end
test "a list of embeds are updated where appropriate" do
assert %{tags_with_id: [tag]} =
author =
Changeset.for_create(
Author,
:create,
%{
tags_with_id: [
%{name: "trainer", score: 10}
]
}
)
|> Ash.create!()
exception =
assert_raise Ash.Error.Invalid,
~r/Invalid value provided for score: must be increasing/,
fn ->
Changeset.for_update(
author,
:update,
%{
tags_with_id: [
%{id: tag.id, score: 1}
]
}
)
|> Ash.update!()
end
assert Enum.at(exception.errors, 0).path == [:tags_with_id, 0]
applied_author =
Changeset.for_update(
author,
:update,
%{
tags_with_id: [
%{id: tag.id, score: 100}
]
}
)
|> Ash.update!()
# The ID of the Tag should not change
assert Enum.map(applied_author.tags_with_id, & &1.id) ==
Enum.map(author.tags_with_id, & &1.id)
end
test "a list of union embeds are updated where appropriate" do
assert %{union_tags_with_id: [%Ash.Union{value: tag}]} =
author =
Changeset.for_create(
Author,
:create,
%{
union_tags_with_id: [
%{name: "trainer", score: 10, type: "tag_with_id"}
]
}
)
|> Ash.create!()
applied_author =
Changeset.for_update(
author,
:update,
%{
union_tags_with_id: [
%{id: tag.id, score: 100, type: "tag_with_id"}
]
}
)
|> Ash.update!()
# The id of the Union Tag should not change
assert Enum.map(applied_author.union_tags_with_id, & &1.value.id) ==
Enum.map(author.union_tags_with_id, & &1.value.id)
end
end