diff --git a/lib/data_layer.ex b/lib/data_layer.ex index bfa90a4c..19abe32d 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -1306,13 +1306,28 @@ defmodule AshPostgres.DataLayer do start_bindings_at: through_binding }) |> Ash.Query.set_context(Map.get(through_relationship, :context)) - |> Ash.Query.do_filter(Map.get(through_relationship, :filter)) + |> then(fn q -> + Ash.Query.do_filter( + q, + fill_relationship_filter_templates( + Map.get(through_relationship, :filter), + source_query, + q + ) + ) + end) |> then(fn q -> # For through-list paths, the first relationship's filter applies to the through table # For many_to_many, the relationship filter is for the destination, not the through table if !is_atom(Map.get(relationship, :through)) || is_nil(Map.get(relationship, :through)) do - Ash.Query.do_filter(q, Map.get(relationship, :filter), + Ash.Query.do_filter( + q, + fill_relationship_filter_templates( + Map.get(relationship, :filter), + source_query, + q + ), parent_stack: [relationship.source] ) else @@ -1589,7 +1604,12 @@ defmodule AshPostgres.DataLayer do resource |> Ash.Query.new() |> Ash.Query.put_context(:data_layer, %{start_bindings_at: 0}) - |> Ash.Query.do_filter(Map.get(prev_rel, :filter)) + |> then(fn q -> + Ash.Query.do_filter( + q, + fill_relationship_filter_templates(Map.get(prev_rel, :filter), source_query, q) + ) + end) |> Ash.Query.set_tenant(source_query.tenant) |> set_lateral_join_prefix(query) |> case do @@ -1615,6 +1635,23 @@ defmodule AshPostgres.DataLayer do end end + # Relationship filters used by `through` relationships may reference + # `^actor/1`, `^context/1`, `^arg/1` or `^tenant/0` templates. These templates + # must be resolved before the filter is compiled into SQL, otherwise the raw + # template (e.g. `{:_context, :sample_context}`) is handed to the SQL + # implementation, which cannot process it. This mirrors how a directly loaded + # relationship filter is hydrated (see `Ash.Actions.Read.Relationships`): + # actor/args/tenant come from the source query while the context is taken from + # the query the filter is being applied to. + defp fill_relationship_filter_templates(filter, source_query, related_query) do + Ash.Expr.fill_template(filter, + actor: source_query.context[:private][:actor], + tenant: source_query.to_tenant, + args: source_query.arguments, + context: related_query.context + ) + end + defp lateral_join_source_query( %{ __ash_bindings__: %{ diff --git a/test/support/resources/through/school.ex b/test/support/resources/through/school.ex index 34a6795f..0a7cd5ce 100644 --- a/test/support/resources/through/school.ex +++ b/test/support/resources/through/school.ex @@ -63,11 +63,38 @@ defmodule AshPostgres.Test.Through.School do public?(true) end + has_many :context_filtered_classrooms, AshPostgres.Test.Through.Classroom do + public?(true) + + filter( + expr( + is_nil(^context(:sample_context)) or + name == ^context(:sample_context) + ) + ) + end + + has_many :actor_filtered_classrooms, AshPostgres.Test.Through.Classroom do + public?(true) + + filter(expr(name == ^actor(:visible_classroom))) + end + has_many :teachers, AshPostgres.Test.Through.Teacher do public?(true) through([:classrooms, :classroom_teachers, :teacher]) end + has_many :context_teachers, AshPostgres.Test.Through.Teacher do + public?(true) + through([:context_filtered_classrooms, :classroom_teachers, :teacher]) + end + + has_many :actor_teachers, AshPostgres.Test.Through.Teacher do + public?(true) + through([:actor_filtered_classrooms, :classroom_teachers, :teacher]) + end + has_many :retired_teachers, AshPostgres.Test.Through.Teacher do public?(true) through([:classrooms, :retired_teachers]) @@ -77,5 +104,17 @@ defmodule AshPostgres.Test.Through.School do public?(true) through([:classrooms, :active_teacher]) end + + # Two-hop `through` relationships that exercise the same template resolution + # on the intermediate (context/actor filtered) relationship. + has_many :context_students, AshPostgres.Test.Through.Student do + public?(true) + through([:context_filtered_classrooms, :students]) + end + + has_many :actor_students, AshPostgres.Test.Through.Student do + public?(true) + through([:actor_filtered_classrooms, :students]) + end end end diff --git a/test/through_relationships_test.exs b/test/through_relationships_test.exs index 2dede0b2..db82eab4 100644 --- a/test/through_relationships_test.exs +++ b/test/through_relationships_test.exs @@ -58,6 +58,90 @@ defmodule AshPostgres.Test.ThroughRelationshipsTest do assert teacher_names == ["Dr. Williams", "Mr. Smith", "Ms. Johnson"] end + test "loads teachers through a context-filtered intermediate relationship", setup do + %{school_1: school_1, classroom_1: classroom_1, classroom_2: classroom_2} = setup + %{teacher_1: teacher_1, teacher_2: teacher_2, teacher_3: teacher_3} = setup + + assign_teacher(classroom_1.id, teacher_1.id) + assign_teacher(classroom_1.id, teacher_2.id) + assign_teacher(classroom_2.id, teacher_2.id) + assign_teacher(classroom_2.id, teacher_3.id) + + # The intermediate `context_filtered_classrooms` relationship filters on + # `^context(:sample_context)`. With no context set the template resolves to + # nil, so `is_nil(^context(:sample_context))` matches and all classrooms + # (and therefore all their teachers) load. + school_with_teachers = Ash.load!(school_1, :context_teachers) + + teacher_names = + school_with_teachers.context_teachers + |> Enum.map(& &1.name) + |> Enum.sort() + + assert teacher_names == ["Dr. Williams", "Mr. Smith", "Ms. Johnson"] + end + + test "actor template in intermediate filter is resolved when loading through", setup do + %{school_1: school_1, classroom_1: classroom_1, classroom_2: classroom_2} = setup + %{teacher_1: teacher_1, teacher_3: teacher_3} = setup + + # "Math 101" == classroom_1 (Mr. Smith), "Science 101" == classroom_2 (Dr. Williams). + assign_teacher(classroom_1.id, teacher_1.id) + assign_teacher(classroom_2.id, teacher_3.id) + + # The intermediate `actor_filtered_classrooms` relationship filters on + # `name == ^actor(:visible_classroom)`. The template resolves to the actor's + # value, restricting the through relationship to the "Math 101" classroom and + # therefore only Mr. Smith. + school_with_teachers = + AshPostgres.Test.Through.School + |> Ash.Query.filter(id == ^school_1.id) + |> Ash.Query.load(:actor_teachers) + |> Ash.read_one!(actor: %{visible_classroom: "Math 101"}) + + teacher_names = + school_with_teachers.actor_teachers + |> Enum.map(& &1.name) + |> Enum.sort() + + assert teacher_names == ["Mr. Smith"] + end + + test "context template is resolved in a two-hop through relationship", setup do + %{school_1: school_1} = setup + + # Two-hop through (`context_filtered_classrooms -> students`) exercises the + # non-recursive through path. With no context the template resolves to nil + # so all classrooms match and every student loads. + school_with_students = Ash.load!(school_1, :context_students) + + student_names = + school_with_students.context_students + |> Enum.map(& &1.name) + |> Enum.sort() + + assert student_names == ["Alice", "Bob"] + end + + test "actor template is resolved in a two-hop through relationship", setup do + %{school_1: school_1} = setup + + # Alice is in "Math 101", Bob is in "Science 101". The actor template + # restricts the intermediate classrooms to "Math 101", so only Alice loads. + school_with_students = + AshPostgres.Test.Through.School + |> Ash.Query.filter(id == ^school_1.id) + |> Ash.Query.load(:actor_students) + |> Ash.read_one!(actor: %{visible_classroom: "Math 101"}) + + student_names = + school_with_students.actor_students + |> Enum.map(& &1.name) + |> Enum.sort() + + assert student_names == ["Alice"] + end + test "has_many through with no results", setup do %{school_2: school_2} = setup