From d4f7572bf2b4e28950d509a23503df30f4ccaff3 Mon Sep 17 00:00:00 2001 From: Philip Capel Date: Fri, 6 Mar 2026 14:34:27 -0600 Subject: [PATCH 1/3] chore: add tests for the nested filters issue https://github.com/ash-project/ash_sql/pull/220 Nested filters failed to compile correctly. This can be tested by setting the ASH_SQL_VERSION to `main` and observing the test failure (at least until the fix is merged). --- test/aggregate_test.exs | 48 ++++++++++++++++++++++++++++++++++ test/support/resources/post.ex | 11 ++++++++ 2 files changed, 59 insertions(+) diff --git a/test/aggregate_test.exs b/test/aggregate_test.exs index 2adfc57d..f3e394a7 100644 --- a/test/aggregate_test.exs +++ b/test/aggregate_test.exs @@ -2256,4 +2256,52 @@ 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. + + 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(: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 other filters in a BooleanExpression. + # We use authorize?: false to bypass Post's organization-based authorization policies. + assert {:ok, [loaded_post]} = + Post + |> Ash.Query.filter(id == ^post.id) + |> Ash.Query.load(:max_rating_with_join_filter) + |> Ash.read(actor: author, authorize?: false) + + assert 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..0e8c5a4b 100644 --- a/test/support/resources/post.ex +++ b/test/support/resources/post.ex @@ -1211,6 +1211,17 @@ 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 From 90631c2b30c44c5799e952ddc9f374ee11d67597 Mon Sep 17 00:00:00 2001 From: Philip Capel Date: Fri, 6 Mar 2026 14:48:42 -0600 Subject: [PATCH 2/3] fix: test setup --- test/aggregate_test.exs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/test/aggregate_test.exs b/test/aggregate_test.exs index f3e394a7..98aba0d8 100644 --- a/test/aggregate_test.exs +++ b/test/aggregate_test.exs @@ -2267,6 +2267,21 @@ defmodule AshSql.AggregateTest do # 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 @@ -2276,6 +2291,7 @@ defmodule AshSql.AggregateTest do 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!() @@ -2293,15 +2309,17 @@ defmodule AshSql.AggregateTest do # 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 other filters in a BooleanExpression. - # We use authorize?: false to bypass Post's organization-based authorization policies. + # 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: author, authorize?: false) + |> Ash.read(actor: user) - assert loaded_post.max_rating_with_join_filter == 10 + # 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 From f7e90abe03401f4add986e803501dd771c7cf832 Mon Sep 17 00:00:00 2001 From: Philip Capel Date: Fri, 6 Mar 2026 14:51:06 -0600 Subject: [PATCH 3/3] fix: formatting --- test/support/resources/post.ex | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/test/support/resources/post.ex b/test/support/resources/post.ex index 0e8c5a4b..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) @@ -1213,15 +1215,17 @@ defmodule AshPostgres.Test.Post do 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))} - ) - ) + 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