diff --git a/test/aggregate_test.exs b/test/aggregate_test.exs index 2adfc57d..98aba0d8 100644 --- a/test/aggregate_test.exs +++ b/test/aggregate_test.exs @@ -2256,4 +2256,70 @@ defmodule AshSql.AggregateTest do "Calculation was not loaded when using page(count: true) with aggregates" end end + + describe "join_filters in aggregate calculations" do + test "Ash.Filter structs in join_filters are properly converted to Ecto expressions" do + # This test reproduces the bug where Ash.Filter structs in join_filters + # are not properly converted to Ecto dynamic expressions, causing: + # ** (Ecto.Query.CastError) value `#Ash.Filter<...>` in `where` cannot be cast to type :boolean + # + # The root cause is in ash_sql/lib/expr.ex - when a BooleanExpression contains + # an Ash.Filter struct as an operand, the private do_dynamic_expr/default_dynamic_expr + # functions don't have a clause to handle it, so the Ash.Filter is passed directly + # to Ecto instead of being converted to a dynamic expression. + # + # The bug triggers when authorization policies create Ash.Filter structs that get + # combined with join_filter expressions in BooleanExpressions. + + # Set up authorization chain: User -> Organization -> Post -> Comment + org = + Organization + |> Ash.Changeset.for_create(:create, %{name: "Test Org"}) + |> Ash.create!() + + user = + User + |> Ash.Changeset.for_create(:create, %{}) + |> Ash.Changeset.manage_relationship(:organization, org, type: :append_and_remove) + |> Ash.create!() + + author = + Author + |> Ash.Changeset.for_create(:create, %{first_name: "Test", last_name: "Author"}) + |> Ash.create!() + + post = + Post + |> Ash.Changeset.for_create(:create, %{title: "test"}) + |> Ash.Changeset.manage_relationship(:organization, org, type: :append_and_remove) + |> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove) + |> Ash.create!() + + comment = + Comment + |> Ash.Changeset.for_create(:create, %{title: "comment", likes: 5}) + |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) + |> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove) + |> Ash.create!() + + Rating + |> Ash.Changeset.for_create(:create, %{score: 10, resource_id: comment.id}) + |> Ash.Changeset.set_context(%{data_layer: %{table: "comment_ratings"}}) + |> Ash.create!() + + # This triggers the bug - loading a calculation that uses join_filters with actor reference. + # The join_filter `expr(author_id == ^actor(:id))` gets resolved to an Ash.Filter struct + # which is then combined with authorization policy filters in a BooleanExpression. + assert {:ok, [loaded_post]} = + Post + |> Ash.Query.filter(id == ^post.id) + |> Ash.Query.load(:max_rating_with_join_filter) + |> Ash.read(actor: user) + + # The rating won't match because the comment's author_id doesn't match user.id, + # but the important thing is the query executes without CastError + assert is_nil(loaded_post.max_rating_with_join_filter) or + loaded_post.max_rating_with_join_filter == 10 + end + end end diff --git a/test/support/resources/post.ex b/test/support/resources/post.ex index 4f6c1daa..145858b7 100644 --- a/test/support/resources/post.ex +++ b/test/support/resources/post.ex @@ -622,6 +622,7 @@ defmodule AshPostgres.Test.Post do attribute :keyword_map, :keyword do public?(true) allow_nil?(true) + constraints( fields: [ display_template: [ @@ -630,6 +631,7 @@ defmodule AshPostgres.Test.Post do ] ) end + attribute(:list_of_stuff, {:array, :map}, public?: true) attribute(:uniq_one, :string, public?: true) attribute(:uniq_two, :string, public?: true) @@ -1211,6 +1213,19 @@ defmodule AshPostgres.Test.Post do calculate(:past_datetime1?, :boolean, expr(now() > datetime)) calculate(:past_datetime2?, :boolean, expr(datetime <= now())) + + # Test case for join_filters bug where Ash.Filter structs are not converted to Ecto expressions + calculate( + :max_rating_with_join_filter, + :integer, + expr( + max([:comments, :ratings], + query: [filter: expr(score > 0)], + field: :score, + join_filters: %{comments: expr(author_id == ^actor(:id))} + ) + ) + ) end aggregates do