diff --git a/priv/resource_snapshots/test_repo/co_authored_posts/20241208221219.json b/priv/resource_snapshots/test_repo/co_authored_posts/20241208221219.json new file mode 100644 index 00000000..4503d9d8 --- /dev/null +++ b/priv/resource_snapshots/test_repo/co_authored_posts/20241208221219.json @@ -0,0 +1,97 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "role", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "was_cancelled_at", + "type": "utc_datetime" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": true, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "co_authored_posts_author_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "authors" + }, + "size": null, + "source": "author_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": true, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "co_authored_posts_post_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "posts" + }, + "size": null, + "source": "post_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "E1F2AC3AED1987928E3A2446584C268EC54D0BCA616D81A495F4AB26E3999444", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "co_authored_posts" +} \ No newline at end of file diff --git a/priv/test_repo/migrations/20241208221219_migrate_resources44.exs b/priv/test_repo/migrations/20241208221219_migrate_resources44.exs new file mode 100644 index 00000000..3665d579 --- /dev/null +++ b/priv/test_repo/migrations/20241208221219_migrate_resources44.exs @@ -0,0 +1,48 @@ +defmodule AshPostgres.TestRepo.Migrations.MigrateResources44 do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create table(:co_authored_posts, primary_key: false) do + add(:role, :text, null: false) + add(:was_cancelled_at, :utc_datetime) + + add( + :author_id, + references(:authors, + column: :id, + name: "co_authored_posts_author_id_fkey", + type: :uuid, + prefix: "public" + ), + primary_key: true, + null: false + ) + + add( + :post_id, + references(:posts, + column: :id, + name: "co_authored_posts_post_id_fkey", + type: :uuid, + prefix: "public" + ), + primary_key: true, + null: false + ) + end + end + + def down do + drop(constraint(:co_authored_posts, "co_authored_posts_author_id_fkey")) + + drop(constraint(:co_authored_posts, "co_authored_posts_post_id_fkey")) + + drop(table(:co_authored_posts)) + end +end diff --git a/test/many_to_many_expr_test.exs b/test/many_to_many_expr_test.exs new file mode 100644 index 00000000..d090aa42 --- /dev/null +++ b/test/many_to_many_expr_test.exs @@ -0,0 +1,212 @@ +defmodule AshPostgres.ManyToManyExprTest do + use AshPostgres.RepoCase, async: false + + alias AshPostgres.Test.Author + alias AshPostgres.Test.CoAuthorPost + alias AshPostgres.Test.Post + + require Ash.Query + + setup ctx do + main_author = + if ctx[:main_author?], + do: create_author(), + else: nil + + co_authors = + if ctx[:co_authors], + do: 1..ctx[:co_authors] + |> Stream.map(& + Author + |> Ash.Changeset.for_create(:create, %{first_name: "John #{&1}", last_name: "Doe"}) + |> Ash.create!() + ) + |> Enum.into([]), + else: [] + + %{ + main_author: main_author, + co_authors: co_authors, + } + end + + def create_author(params \\ %{first_name: "John", last_name: "Doe"}) do + Author + |> Ash.Changeset.for_create(:create, params) + |> Ash.create!() + end + + def create_post(author) do + Post + |> Ash.Changeset.for_create(:create, %{title: "Post by #{author.first_name}"}) + |> Ash.create!() + end + + def create_co_author_post(author, post, role) do + CoAuthorPost + |> Ash.Changeset.for_create(:create, %{author_id: author.id, post_id: post.id, role: role}) + |> Ash.create!() + end + + def get_author!(author_id) do + Author + |> Ash.Query.new() + |> Ash.Query.filter(id == ^author_id) + |> Ash.Query.load([:all_co_authored_posts, :cancelled_co_authored_posts, :editor_of, :writer_of]) + |> Ash.read_one!() + end + + def get_co_author_post!(a_id, p_id) do + CoAuthorPost + |> Ash.Query.new() + |> Ash.Query.filter(author_id == ^a_id and post_id == ^p_id) + |> Ash.read_one!() + end + + def get_post!(post_id) do + Post.get_by_id!(post_id, load: [:co_author_posts, :co_authors_unfiltered, :co_authors]) + end + + def cancel(author, post) do + get_co_author_post!(author.id, post.id) + |> CoAuthorPost.cancel() + end + + def uncancel(author, post) do + get_co_author_post!(author.id, post.id) + |> CoAuthorPost.uncancel() + end + + describe "manual join-resource insertion" do + @tag main_author?: true + @tag co_authors: 3 + test "filter on many_to_many relationship using parent works as expected - basic", + %{ + main_author: main_author, + co_authors: co_authors, + } do + post = create_post(main_author) + + [first_ca, second_ca, third_ca] = co_authors + + # Add first co-author + create_co_author_post(first_ca, post, :editor) + + first_ca = get_author!(first_ca.id) + post = get_post!(post.id) + + assert Enum.count(post.co_authors) == 1 + assert Enum.count(first_ca.all_co_authored_posts) == 1 + assert Enum.count(first_ca.editor_of) == 1 + assert Enum.count(first_ca.writer_of) == 0 + assert Enum.count(first_ca.cancelled_co_authored_posts) == 0 + + # Add second co-author + create_co_author_post(second_ca, post, :writer) + + second_ca = get_author!(second_ca.id) + post = get_post!(post.id) + + assert Enum.count(post.co_authors) == 2 + assert Enum.count(second_ca.all_co_authored_posts) == 1 + assert Enum.count(second_ca.editor_of) == 0 + assert Enum.count(second_ca.writer_of) == 1 + assert Enum.count(second_ca.cancelled_co_authored_posts) == 0 + + # Add third co-author + create_co_author_post(third_ca, post, :proof_reader) + + third_ca = get_author!(third_ca.id) + post = get_post!(post.id) + + assert Enum.count(post.co_authors) == 3 + assert Enum.count(third_ca.all_co_authored_posts) == 1 + assert Enum.count(third_ca.editor_of) == 0 + assert Enum.count(third_ca.writer_of) == 0 + assert Enum.count(third_ca.cancelled_co_authored_posts) == 0 + end + + @tag main_author?: true + @tag co_authors: 4 + test "filter on many_to_many relationship using parent works as expected - cancelled", + %{ + main_author: main_author, + co_authors: co_authors, + } do + first_post = create_post(main_author) + second_post = create_post(main_author) + + [first_ca, second_ca, third_ca, fourth_ca] = co_authors + + # Add first co-author + create_co_author_post(first_ca, first_post, :editor) + create_co_author_post(first_ca, second_post, :writer) + + first_ca = get_author!(first_ca.id) + first_post = get_post!(first_post.id) + + assert Enum.count(first_post.co_authors) == 1 + assert Enum.count(first_post.co_authors_unfiltered) == 1 + + assert Enum.count(first_ca.all_co_authored_posts) == 2 + assert Enum.count(first_ca.editor_of) == 1 + assert Enum.count(first_ca.writer_of) == 1 + assert Enum.count(first_ca.cancelled_co_authored_posts) == 0 + + # Add second co-author + create_co_author_post(second_ca, first_post, :proof_reader) + create_co_author_post(second_ca, second_post, :writer) + + second_ca = get_author!(second_ca.id) + first_post = get_post!(first_post.id) + second_post = get_post!(second_post.id) + + assert Enum.count(second_post.co_authors) == 2 + assert Enum.count(second_post.co_authors_unfiltered) == 2 + + assert Enum.count(second_ca.all_co_authored_posts) == 2 + assert Enum.count(second_ca.editor_of) == 0 + assert Enum.count(second_ca.writer_of) == 1 + assert Enum.count(second_ca.cancelled_co_authored_posts) == 0 + + # Add third co-author + create_co_author_post(third_ca, first_post, :proof_reader) + create_co_author_post(third_ca, second_post, :proof_reader) + cancel(third_ca, second_post) + + third_ca = get_author!(third_ca.id) + first_post = get_post!(first_post.id) + second_post = get_post!(second_post.id) + + assert Enum.count(first_post.co_authors) == 3 + assert Enum.count(first_post.co_authors_unfiltered) == 3 + assert Enum.count(second_post.co_authors) == 2 + assert Enum.count(second_post.co_authors_unfiltered) == 3 + + assert Enum.count(third_ca.all_co_authored_posts) == 2 + assert Enum.count(third_ca.editor_of) == 0 + assert Enum.count(third_ca.writer_of) == 0 + assert Enum.count(third_ca.cancelled_co_authored_posts) == 1 + + # Add fourth co-author + create_co_author_post(fourth_ca, first_post, :proof_reader) + create_co_author_post(fourth_ca, second_post, :editor) + cancel(fourth_ca, first_post) + cancel(fourth_ca, second_post) + + fourth_ca = get_author!(fourth_ca.id) + first_post = get_post!(first_post.id) + second_post = get_post!(second_post.id) + + assert Enum.count(first_post.co_authors) == 3 + assert Enum.count(first_post.co_authors_unfiltered) == 4 + assert Enum.count(second_post.co_authors) == 2 + assert Enum.count(second_post.co_authors_unfiltered) == 4 + + assert Enum.count(fourth_ca.all_co_authored_posts) == 2 + assert Enum.count(fourth_ca.editor_of) == 1 + assert Enum.count(fourth_ca.writer_of) == 0 + assert Enum.count(fourth_ca.cancelled_co_authored_posts) == 2 + end + end +end diff --git a/test/support/domain.ex b/test/support/domain.ex index a571efb9..60d2275a 100644 --- a/test/support/domain.ex +++ b/test/support/domain.ex @@ -3,6 +3,7 @@ defmodule AshPostgres.Test.Domain do use Ash.Domain resources do + resource(AshPostgres.Test.CoAuthorPost) resource(AshPostgres.Test.Post) resource(AshPostgres.Test.Comment) resource(AshPostgres.Test.IntegerPost) diff --git a/test/support/resources/author.ex b/test/support/resources/author.ex index dd358005..d01a2a70 100644 --- a/test/support/resources/author.ex +++ b/test/support/resources/author.ex @@ -53,6 +53,41 @@ defmodule AshPostgres.Test.Author do destination_attribute(:first_name) filter(expr(parent(id) != id)) end + + has_many :credited_posts, AshPostgres.Test.CoAuthorPost do + public? true + + destination_attribute :author_id + end + + many_to_many :all_co_authored_posts, AshPostgres.Test.Post do + public? true + join_relationship :credited_posts + source_attribute_on_join_resource :author_id + destination_attribute_on_join_resource :post_id + end + + many_to_many :writer_of, AshPostgres.Test.Post do + public? true + join_relationship :credited_posts + source_attribute_on_join_resource :author_id + destination_attribute_on_join_resource :post_id + filter expr(parent(credited_posts.role) == :writer) + end + + many_to_many :editor_of, AshPostgres.Test.Post do + public? true + join_relationship :credited_posts + source_attribute_on_join_resource :author_id + destination_attribute_on_join_resource :post_id + filter expr(parent(credited_posts.role) == :editor) + end + + many_to_many :cancelled_co_authored_posts, AshPostgres.Test.Post do + public? true + join_relationship :credited_posts + filter expr(not is_nil(parent(credited_posts.was_cancelled_at))) + end end aggregates do diff --git a/test/support/resources/co_authored_post.ex b/test/support/resources/co_authored_post.ex new file mode 100644 index 00000000..b8ad60bb --- /dev/null +++ b/test/support/resources/co_authored_post.ex @@ -0,0 +1,61 @@ +defmodule AshPostgres.Test.CoAuthorPost do + @moduledoc false + use Ash.Resource, + domain: AshPostgres.Test.Domain, + data_layer: AshPostgres.DataLayer + + postgres do + table "co_authored_posts" + repo AshPostgres.TestRepo + end + + attributes do + attribute :role, :atom do + allow_nil? false + public? true + + constraints one_of: [:editor, :writer, :proof_reader] + end + + attribute :was_cancelled_at, :datetime do + allow_nil? true + public? true + end + end + + actions do + default_accept :* + + defaults [:read, :update, :destroy] + + create :create do + end + + update :cancel_author do + change set_attribute(:was_cancelled_at, DateTime.utc_now()) + end + + update :uncancel_author do + change set_attribute(:was_cancelled_at, nil) + end + end + + code_interface do + define :cancel, action: :cancel_author + define :uncancel, action: :uncancel_author + end + + relationships do + belongs_to :author, AshPostgres.Test.Author do + primary_key? true + public? true + allow_nil? false + end + + belongs_to :post, AshPostgres.Test.Post do + primary_key? true + public? true + allow_nil? false + end + end +end diff --git a/test/support/resources/post.ex b/test/support/resources/post.ex index db11f897..c3f029a4 100644 --- a/test/support/resources/post.ex +++ b/test/support/resources/post.ex @@ -448,6 +448,24 @@ defmodule AshPostgres.Test.Post do public?(true) end + has_many :co_author_posts, AshPostgres.Test.CoAuthorPost do + public? true + + destination_attribute :post_id + end + + many_to_many :co_authors, AshPostgres.Test.Author do + public? true + join_relationship :co_author_posts + + filter expr(is_nil(parent(co_author_posts.was_cancelled_at))) + end + + many_to_many :co_authors_unfiltered, AshPostgres.Test.Author do + public? true + join_relationship :co_author_posts + end + has_many :posts_with_matching_title, __MODULE__ do public?(true) no_attributes?(true)