ash/test/actions/load_test.exs
2024-08-14 11:46:18 -04:00

1822 lines
49 KiB
Elixir

defmodule Ash.Test.Actions.LoadTest do
@moduledoc false
use ExUnit.Case, async: false
require Ash.Query
alias Ash.Test.Domain, as: Domain
defmodule Campaign do
@moduledoc false
use Ash.Resource,
domain: Domain,
data_layer: Ash.DataLayer.Ets
ets do
private?(true)
end
actions do
default_accept :*
defaults([:read, :destroy, create: :*, update: :*])
end
attributes do
uuid_primary_key(:id)
attribute(:name, :ci_string, public?: true)
end
end
defmodule Bio do
use Ash.Resource,
authorizers: [Ash.Policy.Authorizer],
data_layer: :embedded
attributes do
attribute(:first_name, :string, public?: true)
attribute(:last_name, :string, public?: true)
attribute :type, :string do
allow_nil?(false)
default("bio")
end
attribute :forbidden_field, :string do
public?(true)
end
end
field_policies do
field_policy :* do
authorize_if always()
end
field_policy :forbidden_field do
forbid_if always()
end
field_policy :forbidden_name do
forbid_if always()
end
end
policies do
policy always() do
authorize_if always()
end
end
calculations do
calculate :full_name, :string, expr(first_name <> " " <> last_name) do
public?(true)
end
calculate :forbidden_name, :string, expr(first_name <> " " <> last_name) do
public?(true)
end
end
end
defmodule OtherKindOfBio do
use Ash.Resource,
authorizers: [Ash.Policy.Authorizer],
data_layer: :embedded
attributes do
attribute(:first_name, :string, public?: true)
attribute(:last_name, :string, public?: true)
attribute :type, :string do
allow_nil?(false)
default("other_kind_of_bio")
end
attribute :forbidden_field, :string do
public?(true)
end
end
field_policies do
field_policy :* do
authorize_if always()
end
field_policy :forbidden_field do
forbid_if always()
end
field_policy :forbidden_name do
forbid_if always()
end
end
policies do
policy always() do
authorize_if always()
end
end
calculations do
calculate :full_name, :string, expr(first_name <> " " <> last_name) do
public?(true)
end
calculate :forbidden_name, :string, expr(first_name <> " " <> last_name) do
public?(true)
end
end
end
defmodule BioUnion do
use Ash.Type.NewType,
subtype_of: :union,
constraints: [
types: [
bio: [
type: Bio,
tag: :type,
tag_value: "bio"
],
other_kind_of_bio: [
type: OtherKindOfBio,
tag: :type,
tag_value: "other_kind_of_bio"
]
]
]
end
defmodule Author do
@moduledoc false
use Ash.Resource,
domain: Domain,
data_layer: Ash.DataLayer.Ets,
authorizers: [
Ash.Test.Authorizer
]
ets do
private?(true)
end
actions do
default_accept :*
defaults([:read, :destroy, create: :*, update: :*])
end
attributes do
uuid_primary_key(:id)
attribute(:name, :string, public?: true)
attribute(:bio, Bio, public?: true)
attribute(:bio_union, BioUnion, public?: true)
end
calculations do
calculate :bio_union_calc, BioUnion, expr(bio_union) do
public?(true)
end
end
aggregates do
count :posts_count, :posts
end
relationships do
has_many(:posts, Ash.Test.Actions.LoadTest.Post,
destination_attribute: :author_id,
public?: true
)
has_one(:latest_post, Ash.Test.Actions.LoadTest.Post,
destination_attribute: :author_id,
sort: [inserted_at: :desc],
public?: true
)
belongs_to :campaign, Ash.Test.Actions.LoadTest.Campaign do
attribute_type(:ci_string)
source_attribute(:campaign_name)
destination_attribute(:name)
public?(true)
end
end
end
defmodule PostsInSameCategory do
use Ash.Resource.ManualRelationship
def load(posts, _, %{query: destination_query, domain: domain}) do
categories = Enum.map(posts, & &1.category)
other_posts =
destination_query
|> Ash.Query.filter(category in ^categories)
|> Ash.read!(domain: domain)
|> Enum.group_by(& &1.category)
{:ok,
Map.new(posts, fn post ->
related_posts =
other_posts
|> Map.get(post.category, [])
|> Enum.reject(&(&1.id == post.id))
{Map.take(post, [:id]), related_posts}
end)}
end
end
defmodule Post do
@moduledoc false
use Ash.Resource,
domain: Domain,
authorizers: [Ash.Policy.Authorizer],
data_layer: Ash.DataLayer.Ets
ets do
private?(true)
end
actions do
default_accept :*
defaults([:read, :destroy, create: :*, update: :*])
read :keyset do
pagination do
required? false
keyset? true
countable true
end
end
read :required_pagination do
pagination do
offset? true
default_limit 20
countable :by_default
end
end
read :all_access
end
attributes do
uuid_primary_key(:id)
attribute(:title, :string, public?: true)
attribute(:contents, :string, public?: true)
attribute(:category, :string, public?: true)
attribute(:secret, :string, public?: true)
timestamps()
end
policies do
policy always() do
authorize_if always()
end
end
field_policies do
field_policy :* do
authorize_if always()
end
field_policy :secret do
authorize_if action([:create, :all_access])
end
end
code_interface do
define :get_by_id do
action(:read)
get_by([:id])
end
end
relationships do
belongs_to :author, Author do
public?(true)
end
has_many :posts_in_same_category, __MODULE__ do
public?(true)
manual(PostsInSameCategory)
end
has_many :ratings, Ash.Test.Actions.LoadTest.Rating do
public?(true)
domain(Ash.Test.Actions.LoadTest.Domain2)
end
has_many :posts_with_same_title, __MODULE__ do
public?(true)
no_attributes? true
filter expr(parent(title) == title and parent(id) != id)
end
many_to_many(:categories, Ash.Test.Actions.LoadTest.Category,
public?: true,
through: Ash.Test.Actions.LoadTest.PostCategory,
destination_attribute_on_join_resource: :category_id,
source_attribute_on_join_resource: :post_id
)
end
end
defmodule PostCategory do
@moduledoc false
use Ash.Resource, domain: Domain, data_layer: Ash.DataLayer.Ets
ets do
private?(true)
end
actions do
default_accept :*
defaults([:read, :destroy, create: :*, update: :*])
end
relationships do
belongs_to(:post, Post, primary_key?: true, allow_nil?: false, public?: true)
belongs_to(:category, Ash.Test.Actions.LoadTest.Category,
primary_key?: true,
allow_nil?: false,
public?: true
)
end
end
defmodule LinkedCategory do
@moduledoc false
use Ash.Resource, domain: Domain, data_layer: Ash.DataLayer.Ets
ets do
private?(true)
end
actions do
default_accept :*
defaults([:read, :destroy, create: :*, update: :*])
end
attributes do
attribute(:source_name, :string, public?: true, primary_key?: true, allow_nil?: false)
attribute(:dest_name, :string, public?: true, primary_key?: true, allow_nil?: false)
end
end
defmodule Category do
@moduledoc false
use Ash.Resource, domain: Domain, data_layer: Ash.DataLayer.Ets
ets do
private?(true)
end
actions do
default_accept :*
defaults([:read, :destroy, create: :*, update: :*])
read :keyset do
pagination do
required? false
keyset? true
end
end
end
attributes do
uuid_primary_key(:id)
attribute(:name, :string, public?: true)
end
relationships do
many_to_many(:posts, Post,
public?: true,
through: PostCategory,
destination_attribute_on_join_resource: :post_id,
source_attribute_on_join_resource: :category_id
)
many_to_many :linked_categories, __MODULE__,
public?: true,
through: LinkedCategory,
source_attribute: :name,
destination_attribute: :name,
destination_attribute_on_join_resource: :dest_name,
source_attribute_on_join_resource: :source_name
end
end
defmodule Rating do
use Ash.Resource,
domain: Ash.Test.Actions.LoadTest.Domain2,
data_layer: Ash.DataLayer.Ets
ets do
private?(true)
end
attributes do
uuid_primary_key(:id)
attribute(:rating, :integer, public?: true)
end
actions do
default_accept :*
defaults([:read, :destroy, create: :*, update: :*])
end
relationships do
belongs_to :post, Post do
public?(true)
domain(Domain)
end
end
end
defmodule Domain2 do
@moduledoc false
use Ash.Domain
resources do
resource Rating
end
end
describe "loads" do
setup do
start_supervised(
{Ash.Test.Authorizer,
strict_check: :authorized,
check: {:error, Ash.Error.Forbidden.exception([])},
strict_check_context: [:query]}
)
:ok
end
test "it allows loading manual relationships" do
post1 =
Post
|> Ash.Changeset.for_create(:create, %{title: "post1", category: "foo"})
|> Ash.create!()
Post
|> Ash.Changeset.for_create(:create, %{title: "post2", category: "bar"})
|> Ash.create!()
post3 =
Post
|> Ash.Changeset.for_create(:create, %{title: "post2", category: "foo"})
|> Ash.create!()
post3_id = post3.id
assert [%{id: ^post3_id}] =
post1
|> Ash.load!(:posts_in_same_category)
|> Map.get(:posts_in_same_category)
end
test "parent expressions can be used for complex constraints" do
post1 =
Post
|> Ash.Changeset.for_create(:create, %{title: "post1", category: "foo"})
|> Ash.create!()
post1_same =
Post
|> Ash.Changeset.for_create(:create, %{title: "post1", category: "bar"})
|> Ash.create!()
Post
|> Ash.Changeset.for_create(:create, %{title: "post2", category: "baz"})
|> Ash.create!()
post1_same_id = post1_same.id
assert [%{id: ^post1_same_id}] =
post1
|> Ash.load!(:posts_with_same_title)
|> Map.get(:posts_with_same_title)
end
test "it allows loading through manual relationships" do
post1 =
Post
|> Ash.Changeset.for_create(:create, %{title: "post1", category: "foo"})
|> Ash.create!()
Post
|> Ash.Changeset.for_create(:create, %{title: "post2", category: "bar"})
|> Ash.create!()
post3 =
Post
|> Ash.Changeset.for_create(:create, %{title: "post2", category: "foo"})
|> Ash.create!()
post3_id = post3.id
assert [%{id: ^post3_id}] =
post1
|> Ash.load!(posts_in_same_category: :ratings)
|> Map.get(:posts_in_same_category)
end
test "it uses `Comp.equal?/2` to support things like ci_string foreign keys" do
author =
Author
|> Ash.Changeset.for_create(:create, %{name: "zerg", campaign_name: "FrEd"})
|> Ash.create!()
Campaign
|> Ash.Changeset.for_create(:create, %{name: "fReD"})
|> Ash.create!()
assert %{
campaign: %{name: %Ash.CiString{string: "fReD"}},
campaign_name: %Ash.CiString{string: "FrEd"}
} = Ash.load!(author, :campaign)
end
test "it allows loading data" do
author =
Author
|> Ash.Changeset.for_create(:create, %{name: "zerg"})
|> Ash.create!()
post1 =
Post
|> Ash.Changeset.for_create(:create, %{title: "post1", author_id: author.id})
|> Ash.create!()
post2 =
Post
|> Ash.Changeset.for_create(:create, %{title: "post2", author_id: author.id})
|> Ash.create!()
assert [fetched_post1, fetched_post2] =
author
|> Ash.load!(:posts)
|> Map.get(:posts)
assert Enum.sort([post1.id, post2.id]) == Enum.sort([fetched_post1.id, fetched_post2.id])
end
test "it allows loading nested related data" do
author =
Author
|> Ash.Changeset.for_create(:create, %{name: "zerg"})
|> Ash.create!()
post1 =
Post
|> Ash.Changeset.for_create(:create, %{title: "post1", author_id: author.id})
|> Ash.create!()
post2 =
Post
|> Ash.Changeset.for_create(:create, %{title: "post2", author_id: author.id})
|> Ash.create!()
[author] =
Author
|> Ash.Query.load(posts: [:author])
|> Ash.Query.filter(posts.id == ^post1.id)
|> Ash.read!(authorize?: true)
assert Enum.sort(Enum.map(author.posts, &Map.get(&1, :id))) ==
Enum.sort([post1.id, post2.id])
for post <- author.posts do
assert post.author.id == author.id
end
end
test "it allows using a custom read action for related data" do
author =
Author
|> Ash.Changeset.for_create(:create, %{name: "zerg"})
|> Ash.create!()
Post
|> Ash.Changeset.for_create(:create, %{title: "post", secret: "42", author_id: author.id})
|> Ash.create!()
all_access_posts_query = Ash.Query.for_read(Post, :all_access)
assert [%{secret: "42"}] =
author
|> Ash.load!(posts: all_access_posts_query)
|> Map.get(:posts)
end
test "unloading related data sets it back to `%Ash.NotLoaded{}`" do
author =
Author
|> Ash.Changeset.for_create(:create, %{name: "zerg"})
|> Ash.create!()
post1 =
Post
|> Ash.Changeset.for_create(:create, %{title: "post1"})
|> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove)
|> Ash.create!()
Post
|> Ash.Changeset.for_create(:create, %{title: "post2"})
|> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove)
|> Ash.create!()
[author] =
Author
|> Ash.Query.load(posts: [:author])
|> Ash.Query.filter(posts.id == ^post1.id)
|> Ash.read!(authorize?: true)
assert author
|> Ash.Resource.unload([:posts, :author])
|> Map.get(:posts)
|> Enum.all?(fn post ->
%Ash.NotLoaded{} = post.author
end)
end
test "loading something does not unload previously loaded things" do
author =
Author
|> Ash.Changeset.for_create(:create, %{name: "zerg"})
|> Ash.create!()
Post
|> Ash.Changeset.for_create(:create, %{title: "post1"})
|> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove)
|> Ash.create!()
Post
|> Ash.Changeset.for_create(:create, %{title: "post2"})
|> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove)
|> Ash.create!()
[author] =
Author
|> Ash.Query.load(:posts)
|> Ash.read!(authorize?: true)
|> Ash.load!(:latest_post)
refute match?(%Ash.NotLoaded{}, author.posts)
refute match?(%Ash.NotLoaded{}, author.latest_post)
end
test "loading something already loaded still loads it unless lazy?: true" do
author =
Author
|> Ash.Changeset.for_create(:create, %{name: "zerg"})
|> Ash.create!()
post1 =
Post
|> Ash.Changeset.for_create(:create, %{title: "post1"})
|> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove)
|> Ash.create!()
post2 =
Post
|> Ash.Changeset.for_create(:create, %{title: "post2"})
|> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove)
|> Ash.create!()
[author] =
Author
|> Ash.Query.load(posts: [:author])
|> Ash.Query.filter(posts.id == ^post1.id)
|> Ash.read!(authorize?: true)
author = Ash.load!(author, :posts_count, lazy?: true)
assert author.posts_count == 2
assert Enum.sort(Enum.map(author.posts, &Map.get(&1, :id))) ==
Enum.sort([post1.id, post2.id])
for post <- author.posts do
assert post.author.id == author.id
end
post3 =
Post
|> Ash.Changeset.for_create(:create, %{title: "post3"})
|> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove)
|> Ash.create!()
author =
author
|> Ash.load!([:posts_count, posts: [:author]], authorize?: true)
assert author.posts_count == 3
assert Enum.sort(Enum.map(author.posts, &Map.get(&1, :id))) ==
Enum.sort([post1.id, post2.id, post3.id])
for post <- author.posts do
assert post.author.id == author.id
end
Post
|> Ash.Changeset.for_create(:create, %{title: "post4"})
|> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove)
|> Ash.create!()
author =
author
|> Ash.load!([:posts_count, posts: [:author]], authorize?: true, lazy?: true)
assert Enum.sort(Enum.map(author.posts, &Map.get(&1, :id))) ==
Enum.sort([post1.id, post2.id, post3.id])
assert author.posts_count == 3
for post <- author.posts do
assert post.author.id == author.id
end
post = Enum.at(author.posts, 0)
post = Map.update!(post, :author, &Map.put(&1, :name, "shouldn't change"))
assert post.author.name == "shouldn't change"
author_after_load =
post |> Ash.load!(:author, authorize?: false, lazy?: true) |> Map.get(:author)
assert author_after_load.name == "shouldn't change"
end
test "nested lazy loads work" do
author =
Author
|> Ash.Changeset.for_create(:create, %{name: "zerg"})
|> Ash.create!()
post1 =
Post
|> Ash.Changeset.for_create(:create, %{title: "post1"})
|> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove)
|> Ash.create!()
Post
|> Ash.Changeset.for_create(:create, %{title: "post2"})
|> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove)
|> Ash.create!()
author =
Author
|> Ash.Query.load(:posts)
|> Ash.Query.filter(posts.id == ^post1.id)
|> Ash.read_one!(authorize?: true)
|> Ash.load!([posts: :author], lazy?: true)
author_id = author.id
assert %{posts: [%{author: %{id: ^author_id}}, %{author: %{id: ^author_id}}]} = author
post =
Post
|> Ash.Query.load(:author)
|> Ash.Query.filter(id == ^post1.id)
|> Ash.read_one!()
|> Ash.load!([author: :posts], lazy?: true)
assert %{author: %{posts: [_, _]}} = post
end
test "it allows loading across domains" do
author =
Author
|> Ash.Changeset.for_create(:create, %{name: "zerg"})
|> Ash.create!()
post =
Post
|> Ash.Changeset.for_create(:create, %{title: "post1"})
|> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove)
|> Ash.create!()
rating =
Rating
|> Ash.Changeset.for_create(:create, %{rating: 10})
|> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove)
|> Ash.create!()
assert [loaded_rating] =
author
|> Ash.load!(posts: :ratings)
|> Map.get(:posts)
|> Enum.at(0)
|> Map.get(:ratings)
assert loaded_rating.id == rating.id
end
test "it allows loading many to many relationships" do
category1 =
Category
|> Ash.Changeset.for_create(:create, %{name: "lame"})
|> Ash.create!()
category2 =
Category
|> Ash.Changeset.for_create(:create, %{name: "cool"})
|> Ash.create!()
post =
Post
|> Ash.Changeset.for_create(:create, %{title: "post1"})
|> Ash.Changeset.manage_relationship(:categories, [category1, category2],
type: :append_and_remove
)
|> Ash.create!()
[post] =
Post
|> Ash.Query.load(:categories)
|> Ash.Query.filter(id == ^post.id)
|> Ash.read!(authorize?: true)
assert [%{id: id1}, %{id: id2}] = post.categories
assert Enum.sort([category1.id, category2.id]) == Enum.sort([id1, id2])
end
test "ETS lateral join handles more records with matching source attribute" do
category1 =
Category
|> Ash.Changeset.for_create(:create, %{name: "lame"})
|> Ash.create!()
Category
|> Ash.Changeset.for_create(:create, %{name: "lame"})
|> Ash.create!()
category3 =
Category
|> Ash.Changeset.for_create(:create, %{name: "cool"})
|> Ash.create!()
LinkedCategory
|> Ash.Changeset.for_create(:create, %{source_name: "lame", dest_name: "cool"})
|> Ash.create!()
linked_category_ids =
category1
|> Ash.load!(linked_categories: Ash.Query.limit(Category, 2))
|> Map.get(:linked_categories)
|> Enum.map(& &1.id)
assert linked_category_ids == [category3.id]
end
test "it allows loading filtered many to many relationships with lateral joins" do
category1 =
Category
|> Ash.Changeset.for_create(:create, %{name: "1"})
|> Ash.create!()
category2 =
Category
|> Ash.Changeset.for_create(:create, %{name: "2"})
|> Ash.create!()
category3 =
Category
|> Ash.Changeset.for_create(:create, %{name: "3"})
|> Ash.create!()
category4 =
Category
|> Ash.Changeset.for_create(:create, %{name: "4"})
|> Ash.create!()
post =
Post
|> Ash.Changeset.for_create(:create, %{title: "post1"})
|> Ash.Changeset.manage_relationship(
:categories,
[category1, category2, category3, category4],
type: :append_and_remove
)
|> Ash.create!()
category_query =
Category
|> Ash.Query.filter(name > "1")
|> Ash.Query.sort(:name)
|> Ash.Query.limit(2)
[post] =
Post
|> Ash.Query.load(categories: category_query)
|> Ash.Query.filter(id == ^post.id)
|> Ash.read!(authorize?: true)
assert [%{id: id1}, %{id: id2}] = post.categories
assert [category2.id, category3.id] == [id1, id2]
end
test "it allows loading nested many to many relationships lazily" do
category1 =
Category
|> Ash.Changeset.for_create(:create, %{name: "lame"})
|> Ash.create!()
category2 =
Category
|> Ash.Changeset.for_create(:create, %{name: "cool"})
|> Ash.create!()
post =
Post
|> Ash.Changeset.for_create(:create, %{title: "post1"})
|> Ash.Changeset.manage_relationship(:categories, [category1, category2],
type: :append_and_remove
)
|> Ash.create!()
assert [_] =
Post
|> Ash.Query.load(:categories)
|> Ash.Query.filter(id == ^post.id)
|> Ash.read!(authorize?: true)
assert %{posts: [%{categories: [_, _]}]} =
Category
|> Ash.Query.filter(id == ^category1.id)
|> Ash.Query.load(:posts)
|> Ash.read_one!()
|> Ash.load!([posts: :categories], lazy?: true)
end
test "it allows loading many to many relationships after the fact" do
category1 =
Category
|> Ash.Changeset.for_create(:create, %{name: "lame"})
|> Ash.create!()
category2 =
Category
|> Ash.Changeset.for_create(:create, %{name: "cool"})
|> Ash.create!()
post =
Post
|> Ash.Changeset.for_create(:create, %{title: "post1"})
|> Ash.Changeset.manage_relationship(:categories, [category1, category2],
type: :append_and_remove
)
|> Ash.create!()
post = Post.get_by_id!(post.id, load: [:categories])
assert [%{id: id1}, %{id: id2}] = post.categories
assert Enum.sort([category1.id, category2.id]) == Enum.sort([id1, id2])
end
test "it produces a nice error message on loading invalid loads" do
assert_raise Ash.Error.Invalid, ~r/:non_existent_thing is not a valid load/, fn ->
Post
|> Ash.Query.load(categories: [posts: :non_existent_thing])
|> Ash.read!(authorize?: true)
end
end
test "it allows loading nested many to many relationships" do
category1 =
Category
|> Ash.Changeset.for_create(:create, %{name: "lame"})
|> Ash.create!()
category2 =
Category
|> Ash.Changeset.for_create(:create, %{name: "cool"})
|> Ash.create!()
post =
Post
|> Ash.Changeset.for_create(:create, %{title: "post1"})
|> Ash.Changeset.manage_relationship(:categories, [category1, category2],
type: :append_and_remove
)
|> Ash.create!()
[post] =
Post
|> Ash.Query.load(categories: :posts)
|> Ash.Query.filter(id == ^post.id)
|> Ash.read!(authorize?: true)
post_id = post.id
assert [%{posts: [%{id: ^post_id}]}, %{posts: [%{id: ^post_id}]}] = post.categories
end
test "it loads sorted relationships in the proper order" do
author =
Author
|> Ash.Changeset.for_create(:create, %{name: "zerg"})
|> Ash.create!()
Post
|> Ash.Changeset.for_create(:create, %{title: "post1"})
|> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove)
|> Ash.create!()
post2 =
Post
|> Ash.Changeset.for_create(:create, %{title: "post2"})
|> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove)
|> Ash.create!()
[author] =
Author
|> Ash.Query.load(:latest_post)
|> Ash.read!()
assert author.latest_post.id == post2.id
end
end
describe "forbidden lazy loads" do
setup do
start_supervised(
{Ash.Test.Authorizer,
strict_check: :authorized_if_actor,
check: {:error, Ash.Error.Forbidden.exception([])},
strict_check_context: [:query]}
)
:ok
end
test "action context is properly passed to the lazy query" do
author =
Author
|> Ash.Changeset.for_create(:create, %{name: "zerg"})
|> Ash.create!(authorize?: false)
post1 =
Post
|> Ash.Changeset.for_create(:create, %{title: "post1"})
|> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove)
|> Ash.create!(authorize?: false)
[author] =
Author
|> Ash.Query.load(:posts)
|> Ash.Query.filter(posts.id == ^post1.id)
|> Ash.read!(authorize?: false)
assert {:ok, _} =
Ash.load(author, [posts: [:author]], authorize?: true, lazy?: true, actor: author)
end
end
test "it returns error with invalid keys" do
post =
Post
|> Ash.Changeset.for_create(:create, %{title: "post1", category: "foo"})
|> Ash.create!()
assert_raise Ash.Error.Invalid, ~r/:invalid_key is not a valid load/, fn ->
post |> Ash.load!([:invalid_key])
end
assert_raise Ash.Error.Invalid, ~r/:invalid_key is not a valid load/, fn ->
Post.get_by_id!(post.id, load: [:invalid_key])
end
end
describe "loading through attributes" do
setup do
start_supervised(
{Ash.Test.Authorizer,
strict_check: :authorized,
check: {:error, Ash.Error.Forbidden.exception([])},
strict_check_context: [:query]}
)
:ok
end
test "can load calculations through attributes" do
Author
|> Ash.Changeset.for_create(
:create,
%{name: "zerg", bio: %{first_name: "donald", last_name: "duck"}},
authorize?: false
)
|> Ash.create!()
assert [%{bio: %{full_name: "donald duck"}}] =
Author
|> Ash.Query.load(bio: :full_name)
|> Ash.read!()
end
test "can load calculations through nil attributes" do
Author
|> Ash.Changeset.for_create(:create, %{name: "zerg"})
|> Ash.create!()
assert [%{bio: nil}] =
Author
|> Ash.Query.load(bio: :full_name)
|> Ash.read!()
end
test "can load calculations through union" do
Author
|> Ash.Changeset.for_create(
:create,
%{name: "zerg", bio_union: %{type: "bio", first_name: "donald", last_name: "duck"}},
authorize?: false
)
|> Ash.create!(authorize?: false)
Author
|> Ash.Changeset.for_create(
:create,
%{
name: "zerg",
bio_union: %{type: "other_kind_of_bio", first_name: "donald", last_name: "duck"}
},
authorize?: false
)
|> Ash.create!()
assert [
%{bio_union: %Ash.Union{value: %{full_name: "donald duck"}}},
%{bio_union: %Ash.Union{value: %{full_name: "donald duck"}}}
] =
Author
|> Ash.Query.load(bio_union: [*: :full_name])
|> Ash.read!()
assert [
%{bio_union: %Ash.Union{value: %{full_name: "donald duck"}}},
%{bio_union: %Ash.Union{value: %{full_name: "donald duck"}}}
] =
Author
|> Ash.Query.load(bio_union: [bio: :full_name, other_kind_of_bio: :full_name])
|> Ash.read!()
end
test "can load calculations through union produced by a calculation" do
Author
|> Ash.Changeset.for_create(
:create,
%{
name: "zerg",
bio_union: %{type: "bio", first_name: "donald", last_name: "duck"}
},
authorize?: false
)
|> Ash.create!()
Author
|> Ash.Changeset.for_create(
:create,
%{
name: "zerg",
bio_union: %{type: "other_kind_of_bio", first_name: "donald", last_name: "duck"}
},
authorize?: false
)
|> Ash.create!()
assert [
%{
bio_union_calc: %Ash.Union{
value: %{
full_name: "donald duck",
forbidden_name: %Ash.ForbiddenField{},
forbidden_field: %Ash.ForbiddenField{}
}
}
},
%{
bio_union_calc: %Ash.Union{
value: %{
full_name: "donald duck",
forbidden_name: %Ash.ForbiddenField{},
forbidden_field: %Ash.ForbiddenField{}
}
}
}
] =
Author
|> Ash.Query.load(bio_union_calc: {%{}, [*: [:full_name, :forbidden_name]]})
|> Ash.read!(actor: %{name: "zerg"}, authorize?: true)
end
end
describe "relationship pagination" do
setup do
start_supervised(
{Ash.Test.Authorizer,
strict_check: :authorized,
check: {:error, Ash.Error.Forbidden.exception([])},
strict_check_context: [:query]}
)
:ok
end
test "it allows paginating has_many relationships with offset pagination" do
author1 =
Author
|> Ash.Changeset.for_create(:create, %{name: "a"})
|> Ash.create!()
author2 =
Author
|> Ash.Changeset.for_create(:create, %{name: "b"})
|> Ash.create!()
for i <- 0..9 do
Post
|> Ash.Changeset.for_create(:create, %{title: "author1 post#{i}", author_id: author1.id})
|> Ash.create!()
Post
|> Ash.Changeset.for_create(:create, %{title: "author2 post#{i}", author_id: author2.id})
|> Ash.create!()
end
paginated_posts =
Post
|> Ash.Query.page(limit: 2, offset: 2)
|> Ash.Query.sort(:title)
assert [author1, author2] =
Author
|> Ash.Query.sort(:name)
|> Ash.Query.load(posts: paginated_posts)
|> Ash.read!()
assert %Ash.Page.Offset{
results: [%{title: "author1 post2"}, %{title: "author1 post3"}]
} = author1.posts
assert %Ash.Page.Offset{
results: [%{title: "author2 post2"}, %{title: "author2 post3"}]
} = author2.posts
assert %Ash.Page.Offset{
results: [%{title: "author1 post4"}, %{title: "author1 post5"}]
} = Ash.page!(author1.posts, :next)
end
test "it allows paginating has_many relationships with keyset pagination" do
author1 =
Author
|> Ash.Changeset.for_create(:create, %{name: "a"})
|> Ash.create!()
author2 =
Author
|> Ash.Changeset.for_create(:create, %{name: "b"})
|> Ash.create!()
for i <- 0..9 do
Post
|> Ash.Changeset.for_create(:create, %{title: "author1 post#{i}", author_id: author1.id})
|> Ash.create!()
Post
|> Ash.Changeset.for_create(:create, %{title: "author2 post#{i}", author_id: author2.id})
|> Ash.create!()
end
paginated_posts =
Post
|> Ash.Query.for_read(:keyset)
|> Ash.Query.page(limit: 2)
|> Ash.Query.sort(:title)
assert [author1, author2] =
Author
|> Ash.Query.sort(:name)
|> Ash.Query.load(posts: paginated_posts)
|> Ash.read!()
assert %Ash.Page.Keyset{
results: [%{title: "author1 post0"}, %{title: "author1 post1"}]
} = author1.posts
assert %Ash.Page.Keyset{
results: [%{title: "author2 post0"}, %{title: "author2 post1"}]
} = author2.posts
assert %Ash.Page.Keyset{
results: [%{title: "author1 post2"}, %{title: "author1 post3"}]
} = Ash.page!(author1.posts, :next)
end
test "it allows paginating many_to_many relationships with offset pagination" do
categories =
for i <- 0..9 do
Category
|> Ash.Changeset.for_create(:create, %{name: "category#{i}"})
|> Ash.create!()
end
categories_0_to_6 = Enum.take(categories, 6)
categories_5_to_9 = Enum.slice(categories, 5..9)
Post
|> Ash.Changeset.for_create(:create, %{title: "a"})
|> Ash.Changeset.manage_relationship(:categories, categories_0_to_6,
type: :append_and_remove
)
|> Ash.create!()
Post
|> Ash.Changeset.for_create(:create, %{title: "b"})
|> Ash.Changeset.manage_relationship(:categories, categories_5_to_9,
type: :append_and_remove
)
|> Ash.create!()
paginated_categories =
Category
|> Ash.Query.page(limit: 2, offset: 2)
|> Ash.Query.sort(:name)
assert [post1, post2] =
Post
|> Ash.Query.sort(:title)
|> Ash.Query.load(categories: paginated_categories)
|> Ash.read!()
assert %Ash.Page.Offset{
results: [%{name: "category2"}, %{name: "category3"}]
} = post1.categories
assert %Ash.Page.Offset{
results: [%{name: "category7"}, %{name: "category8"}]
} = post2.categories
assert %Ash.Page.Offset{
results: [%{name: "category4"}, %{name: "category5"}]
} = Ash.page!(post1.categories, :next)
end
test "it allows paginating many_to_many relationships with keyset pagination" do
categories =
for i <- 0..9 do
Category
|> Ash.Changeset.for_create(:create, %{name: "category#{i}"})
|> Ash.create!()
end
categories_0_to_6 = Enum.take(categories, 6)
categories_5_to_9 = Enum.slice(categories, 5..9)
Post
|> Ash.Changeset.for_create(:create, %{title: "a"})
|> Ash.Changeset.manage_relationship(:categories, categories_0_to_6,
type: :append_and_remove
)
|> Ash.create!()
Post
|> Ash.Changeset.for_create(:create, %{title: "b"})
|> Ash.Changeset.manage_relationship(:categories, categories_5_to_9,
type: :append_and_remove
)
|> Ash.create!()
paginated_categories =
Category
|> Ash.Query.for_read(:keyset)
|> Ash.Query.page(limit: 2)
|> Ash.Query.sort(:name)
assert [post1, post2] =
Post
|> Ash.Query.sort(:title)
|> Ash.Query.load(categories: paginated_categories)
|> Ash.read!()
assert %Ash.Page.Keyset{
results: [%{name: "category0"}, %{name: "category1"}]
} = post1.categories
assert %Ash.Page.Keyset{
results: [%{name: "category5"}, %{name: "category6"}]
} = post2.categories
assert %Ash.Page.Keyset{
results: [%{name: "category2"}, %{name: "category3"}]
} = Ash.page!(post1.categories, :next)
end
test "works when nested with offset" do
author1 =
Author
|> Ash.Changeset.for_create(:create, %{name: "a"})
|> Ash.create!()
author2 =
Author
|> Ash.Changeset.for_create(:create, %{name: "b"})
|> Ash.create!()
categories =
for i <- 0..9 do
Category
|> Ash.Changeset.for_create(:create, %{name: "category#{i}"})
|> Ash.create!()
end
categories_0_to_6 = Enum.take(categories, 6)
categories_5_to_9 = Enum.slice(categories, 5..9)
for i <- 0..5 do
Post
|> Ash.Changeset.for_create(:create, %{title: "author1 post#{i}", author_id: author1.id})
|> Ash.Changeset.manage_relationship(:categories, categories_0_to_6,
type: :append_and_remove
)
|> Ash.create!()
Post
|> Ash.Changeset.for_create(:create, %{title: "author2 post#{i}", author_id: author2.id})
|> Ash.Changeset.manage_relationship(:categories, categories_5_to_9,
type: :append_and_remove
)
|> Ash.create!()
end
paginated_categories =
Category
|> Ash.Query.page(limit: 1, offset: 2)
|> Ash.Query.sort(:name)
paginated_posts =
Post
|> Ash.Query.load(categories: paginated_categories)
|> Ash.Query.page(limit: 1, offset: 1)
|> Ash.Query.sort(:title)
assert [author1, _author2] =
Author
|> Ash.Query.sort(:name)
|> Ash.Query.load(posts: paginated_posts)
|> Ash.read!()
assert %Ash.Page.Offset{
results: [
%{
title: "author1 post1",
categories: %Ash.Page.Offset{results: [%{name: "category2"}]}
}
]
} = author1.posts
end
test "works when nested with keyset" do
author1 =
Author
|> Ash.Changeset.for_create(:create, %{name: "a"})
|> Ash.create!()
author2 =
Author
|> Ash.Changeset.for_create(:create, %{name: "b"})
|> Ash.create!()
categories =
for i <- 0..9 do
Category
|> Ash.Changeset.for_create(:create, %{name: "category#{i}"})
|> Ash.create!()
end
categories_0_to_6 = Enum.take(categories, 6)
categories_5_to_9 = Enum.slice(categories, 5..9)
for i <- 0..5 do
Post
|> Ash.Changeset.for_create(:create, %{title: "author1 post#{i}", author_id: author1.id})
|> Ash.Changeset.manage_relationship(:categories, categories_0_to_6,
type: :append_and_remove
)
|> Ash.create!()
Post
|> Ash.Changeset.for_create(:create, %{title: "author2 post#{i}", author_id: author2.id})
|> Ash.Changeset.manage_relationship(:categories, categories_5_to_9,
type: :append_and_remove
)
|> Ash.create!()
end
paginated_categories =
Category
|> Ash.Query.for_read(:keyset)
|> Ash.Query.page(limit: 1)
|> Ash.Query.sort(:name)
paginated_posts =
Post
|> Ash.Query.for_read(:keyset)
|> Ash.Query.load(categories: paginated_categories)
|> Ash.Query.page(limit: 1)
|> Ash.Query.sort(:title)
assert [author1, _author2] =
Author
|> Ash.Query.sort(:name)
|> Ash.Query.load(posts: paginated_posts)
|> Ash.read!()
assert %Ash.Page.Keyset{
results: [
%{
title: "author1 post0",
categories: %Ash.Page.Keyset{results: [%{name: "category0"}]}
}
]
} = author1.posts
end
test "doesn't honor required? pagination to maintain backwards compatibility" do
author =
Author
|> Ash.Changeset.for_create(:create, %{name: "a"})
|> Ash.create!()
Post
|> Ash.Changeset.for_create(:create, %{title: "b"})
|> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove)
|> Ash.create!()
posts =
Post
|> Ash.Query.for_read(:required_pagination)
assert [_post] =
Author
|> Ash.read!()
|> Ash.load!(posts: posts)
end
test "it allows counting has_many relationships" do
author1 =
Author
|> Ash.Changeset.for_create(:create, %{name: "a"})
|> Ash.create!()
author2 =
Author
|> Ash.Changeset.for_create(:create, %{name: "b"})
|> Ash.create!()
for i <- 1..3 do
Post
|> Ash.Changeset.for_create(:create, %{title: "author1 post#{i}", author_id: author1.id})
|> Ash.create!()
end
for i <- 1..6 do
Post
|> Ash.Changeset.for_create(:create, %{title: "author2 post#{i}", author_id: author2.id})
|> Ash.create!()
end
paginated_posts =
Post
|> Ash.Query.page(limit: 2, offset: 2, count: true)
assert [author1, author2] =
Author
|> Ash.Query.sort(:name)
|> Ash.Query.load(posts: paginated_posts)
|> Ash.read!()
assert %Ash.Page.Offset{count: 3} = author1.posts
assert %Ash.Page.Offset{count: 6} = author2.posts
end
test "it allows counting many_to_many relationships" do
categories =
for i <- 1..9 do
Category
|> Ash.Changeset.for_create(:create, %{name: "category#{i}"})
|> Ash.create!()
end
categories_1_to_3 = Enum.take(categories, 3)
categories_4_to_9 = Enum.slice(categories, 3..9)
Post
|> Ash.Changeset.for_create(:create, %{title: "a"})
|> Ash.Changeset.manage_relationship(:categories, categories_1_to_3,
type: :append_and_remove
)
|> Ash.create!()
Post
|> Ash.Changeset.for_create(:create, %{title: "b"})
|> Ash.Changeset.manage_relationship(:categories, categories_4_to_9,
type: :append_and_remove
)
|> Ash.create!()
paginated_categories =
Category
|> Ash.Query.page(limit: 2, count: true)
|> Ash.Query.sort(:name)
assert [post1, post2] =
Post
|> Ash.Query.sort(:title)
|> Ash.Query.load(categories: paginated_categories)
|> Ash.read!()
assert %Ash.Page.Offset{count: 3} = post1.categories
assert %Ash.Page.Offset{count: 6} = post2.categories
end
test "allows counting nested relationships" do
author1 =
Author
|> Ash.Changeset.for_create(:create, %{name: "a"})
|> Ash.create!()
_author2 =
Author
|> Ash.Changeset.for_create(:create, %{name: "b"})
|> Ash.create!()
categories =
for i <- 1..3 do
Category
|> Ash.Changeset.for_create(:create, %{name: "category#{i}"})
|> Ash.create!()
end
for i <- 1..5 do
Post
|> Ash.Changeset.for_create(:create, %{title: "author1 post#{i}", author_id: author1.id})
|> Ash.Changeset.manage_relationship(:categories, categories, type: :append_and_remove)
|> Ash.create!()
end
paginated_categories =
Category
|> Ash.Query.page(limit: 1, count: true)
paginated_posts =
Post
|> Ash.Query.load(categories: paginated_categories)
|> Ash.Query.page(limit: 1, count: true)
assert %Ash.Page.Offset{results: [author1], count: 2} =
Author
|> Ash.Query.sort(:name)
|> Ash.Query.load(posts: paginated_posts)
|> Ash.read!(page: [limit: 1, count: true])
assert %Ash.Page.Offset{count: 5, results: [%{categories: %Ash.Page.Offset{count: 3}}]} =
author1.posts
end
test "doesn't leak the internal count aggregate when counting" do
author =
Author
|> Ash.Changeset.for_create(:create, %{name: "a"})
|> Ash.create!()
for i <- 1..3 do
Post
|> Ash.Changeset.for_create(:create, %{title: "author1 post#{i}", author_id: author.id})
|> Ash.create!()
end
paginated_posts =
Post
|> Ash.Query.page(limit: 2, offset: 2, count: true)
assert [author] =
Author
|> Ash.Query.load(posts: paginated_posts)
|> Ash.read!()
assert %Ash.Page.Offset{count: 3} = author.posts
assert %{} == author.aggregates
end
test "strict?: true option only loads specified fields on related resource" do
author =
Author
|> Ash.Changeset.for_create(:create, %{name: "a"})
|> Ash.create!()
post =
Post
|> Ash.Changeset.for_create(:create, %{
title: "author post",
contents: "post content",
author_id: author.id
})
|> Ash.create!()
assert [author] =
Author
|> Ash.Query.load([posts: [:title]], strict?: true)
|> Ash.read!()
[loaded_post] = author.posts
assert post.contents == "post content"
assert loaded_post.title == "author post"
assert %Ash.NotLoaded{} = loaded_post.contents
end
test "all fields are loaded if no fields are specified when using strict?: true" do
author =
Author
|> Ash.Changeset.for_create(:create, %{name: "a"})
|> Ash.create!()
post =
Post
|> Ash.Changeset.for_create(:create, %{
title: "author post",
contents: "post content",
author_id: author.id
})
|> Ash.create!()
assert [author] =
Author
|> Ash.Query.load([:posts], strict?: true)
|> Ash.read!()
[loaded_post] = author.posts
assert post.title == loaded_post.title
assert post.contents == loaded_post.contents
end
test "strict?: true option only loads specified fields on related resource for Ash.load" do
author =
Author
|> Ash.Changeset.for_create(:create, %{name: "a"})
|> Ash.create!()
post =
Post
|> Ash.Changeset.for_create(:create, %{
title: "author post",
contents: "post content",
author_id: author.id
})
|> Ash.create!()
loaded_author = Ash.load!(author, [posts: [:title]], strict?: true)
[loaded_post] = loaded_author.posts
assert post.contents == "post content"
assert loaded_post.title == "author post"
assert %Ash.NotLoaded{} = loaded_post.contents
end
test "all fields are loaded if no fields are specified when using strict?: true for Ash.load" do
author =
Author
|> Ash.Changeset.for_create(:create, %{name: "a"})
|> Ash.create!()
post =
Post
|> Ash.Changeset.for_create(:create, %{
title: "author post",
contents: "post content",
author_id: author.id
})
|> Ash.create!()
loaded_author = Ash.load!(author, [:posts], strict?: true)
[loaded_post] = loaded_author.posts
assert post.title == loaded_post.title
assert post.contents == loaded_post.contents
end
end
end