Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions lib/data_layer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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__: %{
Expand Down
39 changes: 39 additions & 0 deletions test/support/resources/through/school.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -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
84 changes: 84 additions & 0 deletions test/through_relationships_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading