From 42f63e803a6670748c0d549226b07719000d1d1b Mon Sep 17 00:00:00 2001 From: ken-kost Date: Thu, 5 Feb 2026 14:18:40 +0100 Subject: [PATCH 1/5] Support multi-hop lateral join queries for through relationships --- lib/data_layer.ex | 470 ++++++++++++++++++++++++++++++---------------- 1 file changed, 305 insertions(+), 165 deletions(-) diff --git a/lib/data_layer.ex b/lib/data_layer.ex index f8b9f084..819279aa 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -1227,10 +1227,10 @@ defmodule AshPostgres.DataLayer do query, root_data, [ - {source_query, source_attribute, source_attribute_on_join_resource, relationship}, - {through_resource, destination_attribute_on_join_resource, destination_attribute, - through_relationship} - ] = path + {source_query, source_attribute, source_attribute_on_join_resource, relationship} + | rest + ] = + path ) do source_query = Ash.Query.new(source_query) source_values = Enum.map(root_data, &Map.get(&1, source_attribute)) @@ -1240,185 +1240,325 @@ defmodule AshPostgres.DataLayer do {:ok, data_layer_query} -> data_layer_query = Ecto.Query.exclude(data_layer_query, :select) - through_binding = Map.get(query, :__ash_bindings__)[:current] - - through_resource - |> Ash.Query.new() - |> Ash.Query.put_context(:data_layer, %{ - start_bindings_at: through_binding - }) - |> Ash.Query.set_context(through_relationship.context) - |> Ash.Query.do_filter(through_relationship.filter) - |> Ash.Query.sort(through_relationship.sort) - |> then(fn q -> - if through_relationship.limit do - Ash.Query.limit(q, through_relationship.limit) - else - q - end - end) - |> Ash.Query.set_tenant(source_query.tenant) - |> set_lateral_join_prefix(query) - |> case do - %{valid?: true} = through_query -> - through_query - |> Ash.Query.data_layer_query() - - query -> - {:error, query} - end - |> case do - {:ok, through_query} -> - through_query = Ecto.Query.exclude(through_query, :select) - - # Determine if we need to wrap through_query in a subquery - # We also need to wrap if any wheres have subqueries, because Ecto's - # query_to_joins converts wheres to on clauses but doesn't preserve subqueries - has_subqueries_in_wheres? = - Enum.any?(through_query.wheres, fn w -> w.subqueries != [] end) - - needs_subquery? = - through_query.limit != nil || through_query.order_bys != [] || - (through_query.joins && through_query.joins != []) || - has_subqueries_in_wheres? || - (Ash.Resource.Info.multitenancy_strategy(relationship.through) == :context && - source_query.tenant) - - through_query = - if needs_subquery? do - # When wrapping in subquery, put parent correlation inside so that - # any limit/order_by is applied per-parent, not globally - through_query = - from(through in through_query, - where: - field(through, ^source_attribute_on_join_resource) == - field(parent_as(^0), ^source_attribute) - ) - - subquery( - set_subquery_prefix( - through_query, - source_query, - relationship.through - ) + case rest do + [ + {through_resource, destination_attribute_on_join_resource, destination_attribute, + through_relationship} + ] -> + through_binding = Map.get(query, :__ash_bindings__)[:current] + + through_resource + |> Ash.Query.new() + |> Ash.Query.put_context(:data_layer, %{ + 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 -> + # 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), + parent_stack: [relationship.source] ) else - set_subquery_prefix(through_query, source_query, relationship.through) + q + end + end) + |> Ash.Query.sort(Map.get(through_relationship, :sort)) + |> then(fn q -> + if Map.get(through_relationship, :limit) do + Ash.Query.limit(q, Map.get(through_relationship, :limit)) + else + q end + end) + |> Ash.Query.set_tenant(source_query.tenant) + |> set_lateral_join_prefix(query) + |> case do + %{valid?: true} = through_query -> + through_query + |> Ash.Query.data_layer_query() + + query -> + {:error, query} + end + |> case do + {:ok, through_query} -> + through_query = Ecto.Query.exclude(through_query, :select) - if query.__ash_bindings__[:__order__?] do - subquery = - if needs_subquery? do - # Parent correlation is already in through_query subquery - subquery( - from( - destination in query, - select_merge: %{__order__: over(row_number(), :order)}, - join: through in ^through_query, - as: ^through_binding, - on: - field(through, ^destination_attribute_on_join_resource) == - field(destination, ^destination_attribute) - ) - |> set_subquery_prefix( - source_query, - relationship.destination + has_subqueries_in_wheres? = + Enum.any?(through_query.wheres, fn w -> w.subqueries != [] end) + + needs_subquery? = + through_query.limit != nil || through_query.order_bys != [] || + (through_query.joins && through_query.joins != []) || + has_subqueries_in_wheres? || + (Ash.Resource.Info.multitenancy_strategy(through_relationship.source) == + :context && + source_query.tenant) + + through_query = + if needs_subquery? do + through_query = + from(through in through_query, + where: + field(through, ^source_attribute_on_join_resource) == + field(parent_as(^0), ^source_attribute) + ) + + subquery( + set_subquery_prefix( + through_query, + source_query, + through_relationship.source + ) ) - ) + else + set_subquery_prefix(through_query, source_query, through_relationship.source) + end + + if query.__ash_bindings__[:__order__?] do + subquery = + if needs_subquery? do + subquery( + from( + destination in query, + select_merge: %{__order__: over(row_number(), :order)}, + join: through in ^through_query, + as: ^through_binding, + on: + field(through, ^destination_attribute_on_join_resource) == + field(destination, ^destination_attribute) + ) + |> set_subquery_prefix( + source_query, + relationship.destination + ) + ) + else + subquery( + from( + destination in query, + select_merge: %{__order__: over(row_number(), :order)}, + join: through in ^through_query, + as: ^through_binding, + on: + field(through, ^destination_attribute_on_join_resource) == + field(destination, ^destination_attribute), + where: + field(through, ^source_attribute_on_join_resource) == + field( + parent_as(^0), + ^source_attribute + ) + ) + |> set_subquery_prefix( + source_query, + relationship.destination + ) + ) + end + + {:ok, + from(source in data_layer_query, + where: field(source, ^source_attribute) in ^source_values, + inner_lateral_join: destination in subquery(get_subquery(subquery)), + on: true, + select: destination, + select_merge: %{__lateral_join_source__: map(source, ^source_pkey)}, + order_by: destination.__order__, + distinct: true + )} else - subquery( - from( - destination in query, - select_merge: %{__order__: over(row_number(), :order)}, - join: through in ^through_query, - as: ^through_binding, - on: - field(through, ^destination_attribute_on_join_resource) == - field(destination, ^destination_attribute), - where: - field(through, ^source_attribute_on_join_resource) == - field( - parent_as(^0), - ^source_attribute - ) - ) - |> set_subquery_prefix( - source_query, - relationship.destination - ) - ) + subquery = + if needs_subquery? do + subquery( + from( + destination in query, + join: through in ^through_query, + as: ^through_binding, + on: + field(through, ^destination_attribute_on_join_resource) == + field(destination, ^destination_attribute) + ) + |> set_subquery_prefix( + source_query, + relationship.destination + ) + ) + else + subquery( + from( + destination in query, + join: through in ^through_query, + as: ^through_binding, + on: + field(through, ^destination_attribute_on_join_resource) == + field(destination, ^destination_attribute), + where: + field(through, ^source_attribute_on_join_resource) == + field( + parent_as(^0), + ^source_attribute + ) + ) + |> set_subquery_prefix( + source_query, + relationship.destination + ) + ) + end + + data_layer_query = Ecto.Query.exclude(data_layer_query, :distinct) + + {:ok, + from(source in data_layer_query, + where: field(source, ^source_attribute) in ^source_values, + inner_lateral_join: destination in subquery(get_subquery(subquery)), + on: true, + select: destination, + select_merge: %{__lateral_join_source__: map(source, ^source_pkey)}, + distinct: true + )} end - {:ok, - from(source in data_layer_query, - where: field(source, ^source_attribute) in ^source_values, - inner_lateral_join: destination in subquery(get_subquery(subquery)), - on: true, - select: destination, - select_merge: %{__lateral_join_source__: map(source, ^source_pkey)}, - order_by: destination.__order__, - distinct: true - )} - else - subquery = - if needs_subquery? do - # Parent correlation is already in through_query subquery - subquery( - from( - destination in query, - join: through in ^through_query, - as: ^through_binding, - on: - field(through, ^destination_attribute_on_join_resource) == - field(destination, ^destination_attribute) - ) - |> set_subquery_prefix( - source_query, - relationship.destination + {:error, error} -> + {:error, error} + end + + rest -> + case build_through_queries(query, source_query, relationship, rest) do + {:ok, through_queries} -> + data_layer_query = Ecto.Query.exclude(data_layer_query, :distinct) + + base_subquery = + if query.__ash_bindings__[:__order__?] do + from(destination in query, + select_merge: %{__order__: over(row_number(), :order)} ) - ) - else - subquery( - from( - destination in query, - join: through in ^through_query, - as: ^through_binding, - on: - field(through, ^destination_attribute_on_join_resource) == - field(destination, ^destination_attribute), - where: - field(through, ^source_attribute_on_join_resource) == - field( - parent_as(^0), - ^source_attribute + else + query + end + + final_subquery = + rest + |> Enum.zip(through_queries) + |> Enum.reverse() + |> Enum.reduce( + {base_subquery, 0}, + fn {{_through_res, src_attr, dst_attr, _through_rel}, + {through_ecto_q, through_module}}, + {sub_q, offset} -> + binding = Map.get(query, :__ash_bindings__)[:current] + offset + is_outermost? = offset == length(rest) - 1 + + prepared_through = + if is_outermost? do + correlated = + from(through in through_ecto_q, + where: + field(through, ^source_attribute_on_join_resource) == + field(parent_as(^0), ^source_attribute) + ) + + subquery(set_subquery_prefix(correlated, source_query, through_module)) + else + through_ecto_q + |> set_subquery_prefix(source_query, through_module) + |> subquery() + end + + new_sub_q = + if offset == 0 do + from(dest in sub_q, + join: through in ^prepared_through, + as: ^binding, + on: + field(through, ^src_attr) == + field(dest, ^dst_attr) ) + else + prev_binding = binding - 1 + + from(dest in sub_q, + join: through in ^prepared_through, + as: ^binding, + on: + field(through, ^src_attr) == + field(as(^prev_binding), ^dst_attr) + ) + end + + {new_sub_q, offset + 1} + end + ) + |> elem(0) + |> set_subquery_prefix(source_query, relationship.destination) + |> subquery() + + result_query = + if query.__ash_bindings__[:__order__?] do + from(source in data_layer_query, + where: field(source, ^source_attribute) in ^source_values, + inner_lateral_join: destination in subquery(get_subquery(final_subquery)), + on: true, + select: destination, + select_merge: %{__lateral_join_source__: map(source, ^source_pkey)}, + order_by: destination.__order__, + distinct: true ) - |> set_subquery_prefix( - source_query, - relationship.destination + else + from(source in data_layer_query, + where: field(source, ^source_attribute) in ^source_values, + inner_lateral_join: destination in subquery(get_subquery(final_subquery)), + on: true, + select: destination, + select_merge: %{__lateral_join_source__: map(source, ^source_pkey)}, + distinct: true ) - ) - end + end - data_layer_query = Ecto.Query.exclude(data_layer_query, :distinct) - - {:ok, - from(source in data_layer_query, - where: field(source, ^source_attribute) in ^source_values, - inner_lateral_join: destination in subquery(get_subquery(subquery)), - on: true, - select: destination, - select_merge: %{__lateral_join_source__: map(source, ^source_pkey)}, - distinct: true - )} + {:ok, result_query} + + {:error, error} -> + {:error, error} end + end + + {:error, error} -> + {:error, error} + end + end + + defp build_through_queries(query, source_query, prev_rel, [{resource, _, _, rel}]) do + resource + |> Ash.Query.new() + |> Ash.Query.put_context(:data_layer, %{start_bindings_at: 0}) + |> Ash.Query.do_filter(Map.get(prev_rel, :filter)) + |> Ash.Query.set_tenant(source_query.tenant) + |> set_lateral_join_prefix(query) + |> case do + %{valid?: true} = tq -> + case Ash.Query.data_layer_query(tq) do + {:ok, ecto_query} -> + result = [{Ecto.Query.exclude(ecto_query, :select), rel.source}] + {:ok, result} {:error, error} -> {:error, error} end - {:error, error} -> - {:error, error} + invalid_query -> + {:error, invalid_query} + end + end + + defp build_through_queries(query, source_query, prev_rel, [{_, _, _, through_rel} = head | rest]) do + with {:ok, rel_query} <- build_through_queries(query, source_query, prev_rel, [head]), + {:ok, rest_queries} <- build_through_queries(query, source_query, through_rel, rest) do + {:ok, rel_query ++ rest_queries} end end @@ -2338,7 +2478,7 @@ defmodule AshPostgres.DataLayer do fields_to_upsert = upsert_fields -- - (Keyword.keys(Enum.at(changesets, 0).atomics) -- keys) + Keyword.keys(Enum.at(changesets, 0).atomics) -- keys fields_to_upsert = case fields_to_upsert do From b6021a0ab86ff2286b3cc549961fa8d6cd3be4a7 Mon Sep 17 00:00:00 2001 From: ken-kost Date: Thu, 5 Feb 2026 14:22:34 +0100 Subject: [PATCH 2/5] Setup support for through relationship tests --- .../classroom_teachers/20260203115732.json | 125 ++++++++++++++++++ .../test_repo/classrooms/20260203115732.json | 75 +++++++++++ .../test_repo/schools/20260203115732.json | 44 ++++++ .../test_repo/students/20260203115732.json | 75 +++++++++++ .../test_repo/teachers/20260203115732.json | 44 ++++++ .../20260203115732_migrate_resources70.exs | 125 ++++++++++++++++++ test/support/domain.ex | 5 + test/support/resources/through/classroom.ex | 60 +++++++++ .../resources/through/classroom_teacher.ex | 67 ++++++++++ test/support/resources/through/school.ex | 68 ++++++++++ test/support/resources/through/student.ex | 43 ++++++ test/support/resources/through/teacher.ex | 36 +++++ 12 files changed, 767 insertions(+) create mode 100644 priv/resource_snapshots/test_repo/classroom_teachers/20260203115732.json create mode 100644 priv/resource_snapshots/test_repo/classrooms/20260203115732.json create mode 100644 priv/resource_snapshots/test_repo/schools/20260203115732.json create mode 100644 priv/resource_snapshots/test_repo/students/20260203115732.json create mode 100644 priv/resource_snapshots/test_repo/teachers/20260203115732.json create mode 100644 priv/test_repo/migrations/20260203115732_migrate_resources70.exs create mode 100644 test/support/resources/through/classroom.ex create mode 100644 test/support/resources/through/classroom_teacher.ex create mode 100644 test/support/resources/through/school.ex create mode 100644 test/support/resources/through/student.ex create mode 100644 test/support/resources/through/teacher.ex diff --git a/priv/resource_snapshots/test_repo/classroom_teachers/20260203115732.json b/priv/resource_snapshots/test_repo/classroom_teachers/20260203115732.json new file mode 100644 index 00000000..05b08aa8 --- /dev/null +++ b/priv/resource_snapshots/test_repo/classroom_teachers/20260203115732.json @@ -0,0 +1,125 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "retired_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "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": "classroom_teachers_classroom_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "classrooms" + }, + "scale": null, + "size": null, + "source": "classroom_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "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": "classroom_teachers_teacher_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "teachers" + }, + "scale": null, + "size": null, + "source": "teacher_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "B05DD910F1EE1F8B6151179F105613E642AB242896D76E54C438FC297BC2B822", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "classroom_teachers_unique_classroom_teacher_index", + "keys": [ + { + "type": "atom", + "value": "classroom_id" + }, + { + "type": "atom", + "value": "teacher_id" + } + ], + "name": "unique_classroom_teacher", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "classroom_teachers" +} \ No newline at end of file diff --git a/priv/resource_snapshots/test_repo/classrooms/20260203115732.json b/priv/resource_snapshots/test_repo/classrooms/20260203115732.json new file mode 100644 index 00000000..dd47962c --- /dev/null +++ b/priv/resource_snapshots/test_repo/classrooms/20260203115732.json @@ -0,0 +1,75 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "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": "classrooms_school_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "schools" + }, + "scale": null, + "size": null, + "source": "school_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "E1ACDC9A51CAA0429F0D820BDB97F17280D04962EF2BF250E30C6C78AA4CD061", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "classrooms" +} \ No newline at end of file diff --git a/priv/resource_snapshots/test_repo/schools/20260203115732.json b/priv/resource_snapshots/test_repo/schools/20260203115732.json new file mode 100644 index 00000000..3775e9b6 --- /dev/null +++ b/priv/resource_snapshots/test_repo/schools/20260203115732.json @@ -0,0 +1,44 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "8C091BCD2FD84EB8B41BE6B4EA39A02A6ED80DD7FE26698FED90C2452936A2CB", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "schools" +} \ No newline at end of file diff --git a/priv/resource_snapshots/test_repo/students/20260203115732.json b/priv/resource_snapshots/test_repo/students/20260203115732.json new file mode 100644 index 00000000..549b8df9 --- /dev/null +++ b/priv/resource_snapshots/test_repo/students/20260203115732.json @@ -0,0 +1,75 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "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": "students_classroom_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "classrooms" + }, + "scale": null, + "size": null, + "source": "classroom_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "6FD7FFCDA0DF4CF8A38BAB8ABD53FC9A4DB0A10A94A0A19B33A560EF5BD2176F", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "students" +} \ No newline at end of file diff --git a/priv/resource_snapshots/test_repo/teachers/20260203115732.json b/priv/resource_snapshots/test_repo/teachers/20260203115732.json new file mode 100644 index 00000000..87e83b9f --- /dev/null +++ b/priv/resource_snapshots/test_repo/teachers/20260203115732.json @@ -0,0 +1,44 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "F839E78D2BC6DECC46D39A81EC374E65A6A96732FFD01777C1D0A5FE4133AC14", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.AshPostgres.TestRepo", + "schema": null, + "table": "teachers" +} \ No newline at end of file diff --git a/priv/test_repo/migrations/20260203115732_migrate_resources70.exs b/priv/test_repo/migrations/20260203115732_migrate_resources70.exs new file mode 100644 index 00000000..70117130 --- /dev/null +++ b/priv/test_repo/migrations/20260203115732_migrate_resources70.exs @@ -0,0 +1,125 @@ +defmodule AshPostgres.TestRepo.Migrations.MigrateResources70 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(:classrooms, primary_key: false) do + add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true) + add(:name, :text) + add(:school_id, :uuid) + end + + create table(:teachers, primary_key: false) do + add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true) + add(:name, :text) + end + + create table(:classroom_teachers, primary_key: false) do + add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true) + add(:retired_at, :utc_datetime_usec) + + add( + :classroom_id, + references(:classrooms, + column: :id, + name: "classroom_teachers_classroom_id_fkey", + type: :uuid, + prefix: "public" + ), + null: false + ) + + add( + :teacher_id, + references(:teachers, + column: :id, + name: "classroom_teachers_teacher_id_fkey", + type: :uuid, + prefix: "public" + ), + null: false + ) + end + + create( + unique_index(:classroom_teachers, [:classroom_id, :teacher_id], + name: "classroom_teachers_unique_classroom_teacher_index" + ) + ) + + create table(:schools, primary_key: false) do + add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true) + end + + alter table(:classrooms) do + modify( + :school_id, + references(:schools, + column: :id, + name: "classrooms_school_id_fkey", + type: :uuid, + prefix: "public" + ) + ) + end + + alter table(:schools) do + add(:name, :text) + end + + create table(:students, primary_key: false) do + add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true) + add(:name, :text) + + add( + :classroom_id, + references(:classrooms, + column: :id, + name: "students_classroom_id_fkey", + type: :uuid, + prefix: "public" + ), + null: false + ) + end + end + + def down do + drop(constraint(:students, "students_classroom_id_fkey")) + + drop(table(:students)) + + alter table(:schools) do + remove(:name) + end + + drop(constraint(:classrooms, "classrooms_school_id_fkey")) + + alter table(:classrooms) do + modify(:school_id, :uuid) + end + + drop(table(:schools)) + + drop_if_exists( + unique_index(:classroom_teachers, [:classroom_id, :teacher_id], + name: "classroom_teachers_unique_classroom_teacher_index" + ) + ) + + drop(constraint(:classroom_teachers, "classroom_teachers_classroom_id_fkey")) + + drop(constraint(:classroom_teachers, "classroom_teachers_teacher_id_fkey")) + + drop(table(:classroom_teachers)) + + drop(table(:teachers)) + + drop(table(:classrooms)) + end +end diff --git a/test/support/domain.ex b/test/support/domain.ex index 4471e2f9..dac03924 100644 --- a/test/support/domain.ex +++ b/test/support/domain.ex @@ -67,6 +67,11 @@ defmodule AshPostgres.Test.Domain do resource(AshPostgres.Test.Container) resource(AshPostgres.Test.Item) resource(AshPostgres.Test.AfterTransactionPost) + resource(AshPostgres.Test.Through.School) + resource(AshPostgres.Test.Through.Classroom) + resource(AshPostgres.Test.Through.Teacher) + resource(AshPostgres.Test.Through.ClassroomTeacher) + resource(AshPostgres.Test.Through.Student) end authorization do diff --git a/test/support/resources/through/classroom.ex b/test/support/resources/through/classroom.ex new file mode 100644 index 00000000..f60ff384 --- /dev/null +++ b/test/support/resources/through/classroom.ex @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.Test.Through.Classroom do + @moduledoc false + use Ash.Resource, + domain: AshPostgres.Test.Domain, + data_layer: AshPostgres.DataLayer + + postgres do + table "classrooms" + repo AshPostgres.TestRepo + end + + attributes do + uuid_primary_key(:id) + attribute(:name, :string, public?: true) + end + + actions do + default_accept(:*) + defaults([:read, :destroy, create: :*, update: :*]) + end + + relationships do + belongs_to :school, AshPostgres.Test.Through.School do + public?(true) + end + + has_many :classroom_teachers, AshPostgres.Test.Through.ClassroomTeacher do + public?(true) + end + + has_many :retired_classroom_teachers, AshPostgres.Test.Through.ClassroomTeacher do + public?(true) + filter(expr(not is_nil(retired_at))) + end + + has_many :students, AshPostgres.Test.Through.Student do + public?(true) + end + + has_many :retired_teachers, AshPostgres.Test.Through.Teacher do + public?(true) + through([:retired_classroom_teachers, :teacher]) + end + + has_one :active_classroom_teacher, AshPostgres.Test.Through.ClassroomTeacher do + public?(true) + from_many?(true) + filter(expr(is_nil(retired_at))) + end + + has_one :active_teacher, AshPostgres.Test.Through.Teacher do + public?(true) + through([:active_classroom_teacher, :teacher]) + end + end +end diff --git a/test/support/resources/through/classroom_teacher.ex b/test/support/resources/through/classroom_teacher.ex new file mode 100644 index 00000000..fe6ea7db --- /dev/null +++ b/test/support/resources/through/classroom_teacher.ex @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.Test.Through.ClassroomTeacher do + @moduledoc false + use Ash.Resource, + domain: AshPostgres.Test.Domain, + data_layer: AshPostgres.DataLayer + + require Ash.Query + + postgres do + table "classroom_teachers" + repo AshPostgres.TestRepo + end + + attributes do + uuid_primary_key(:id) + + attribute :retired_at, :utc_datetime_usec do + public?(true) + allow_nil?(true) + end + end + + actions do + default_accept(:*) + defaults([:read, :destroy, update: :*]) + + create :assign do + primary?(true) + upsert?(true) + upsert_identity(:unique_classroom_teacher) + + change(fn changeset, _context -> + Ash.Changeset.after_action(changeset, fn _changeset, record -> + __MODULE__ + |> Ash.Query.filter(classroom_id == ^record.classroom_id and is_nil(retired_at)) + |> Ash.Query.filter(id != ^record.id) + |> Ash.bulk_update!(:update, %{retired_at: DateTime.utc_now()}, + authorize?: false, + return_records?: false + ) + + {:ok, record} + end) + end) + end + end + + relationships do + belongs_to :classroom, AshPostgres.Test.Through.Classroom do + public?(true) + allow_nil?(false) + end + + belongs_to :teacher, AshPostgres.Test.Through.Teacher do + public?(true) + allow_nil?(false) + end + end + + identities do + identity(:unique_classroom_teacher, [:classroom_id, :teacher_id]) + end +end diff --git a/test/support/resources/through/school.ex b/test/support/resources/through/school.ex new file mode 100644 index 00000000..71173bbe --- /dev/null +++ b/test/support/resources/through/school.ex @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.Test.Through.School do + @moduledoc false + use Ash.Resource, + domain: AshPostgres.Test.Domain, + data_layer: AshPostgres.DataLayer + + postgres do + table "schools" + repo AshPostgres.TestRepo + end + + attributes do + uuid_primary_key(:id) + attribute(:name, :string, public?: true) + end + + actions do + default_accept(:*) + defaults([:read, :destroy, create: :*, update: :*]) + end + + aggregates do + count :classroom_count, :classrooms do + public?(true) + end + + count :teacher_count, :teachers do + public?(true) + end + + count :teacher_count_via_path, [:classrooms, :classroom_teachers, :teacher] do + public?(true) + end + + count :retired_teacher_count, :retired_teachers do + public?(true) + end + + count :active_teacher_count, :active_teachers do + public?(true) + end + end + + relationships do + has_many :classrooms, AshPostgres.Test.Through.Classroom do + public?(true) + end + + has_many :teachers, AshPostgres.Test.Through.Teacher do + public?(true) + through([:classrooms, :classroom_teachers, :teacher]) + end + + has_many :retired_teachers, AshPostgres.Test.Through.Teacher do + public?(true) + through([:classrooms, :retired_teachers]) + end + + has_many :active_teachers, AshPostgres.Test.Through.Teacher do + public?(true) + through([:classrooms, :active_teacher]) + end + end +end diff --git a/test/support/resources/through/student.ex b/test/support/resources/through/student.ex new file mode 100644 index 00000000..ed526cd3 --- /dev/null +++ b/test/support/resources/through/student.ex @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.Test.Through.Student do + @moduledoc false + use Ash.Resource, + domain: AshPostgres.Test.Domain, + data_layer: AshPostgres.DataLayer + + postgres do + table "students" + repo AshPostgres.TestRepo + end + + attributes do + uuid_primary_key(:id) + attribute(:name, :string, public?: true) + end + + actions do + default_accept(:*) + defaults([:read, :destroy, create: :*, update: :*]) + end + + aggregates do + count :retired_teacher_count, [:classroom, :retired_teachers] do + public?(true) + end + end + + relationships do + belongs_to :classroom, AshPostgres.Test.Through.Classroom do + public?(true) + allow_nil?(false) + end + + has_one :teacher, AshPostgres.Test.Through.Teacher do + public?(true) + through([:classroom, :active_teacher]) + end + end +end diff --git a/test/support/resources/through/teacher.ex b/test/support/resources/through/teacher.ex new file mode 100644 index 00000000..529a68c9 --- /dev/null +++ b/test/support/resources/through/teacher.ex @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.Test.Through.Teacher do + @moduledoc false + use Ash.Resource, + domain: AshPostgres.Test.Domain, + data_layer: AshPostgres.DataLayer + + postgres do + table "teachers" + repo AshPostgres.TestRepo + end + + attributes do + uuid_primary_key(:id) + attribute(:name, :string, public?: true) + end + + actions do + default_accept(:*) + defaults([:read, :destroy, create: :*, update: :*]) + end + + relationships do + has_many :classroom_teachers, AshPostgres.Test.Through.ClassroomTeacher do + public?(true) + end + + has_many :classrooms, AshPostgres.Test.Through.Classroom do + public?(true) + through([:classroom_teachers, :classroom]) + end + end +end From 72691917735b35126982bd5f1ba144d8cae57217 Mon Sep 17 00:00:00 2001 From: ken-kost Date: Thu, 5 Feb 2026 14:25:11 +0100 Subject: [PATCH 3/5] Add through relationships tests --- test/through_relationships_test.exs | 302 ++++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 test/through_relationships_test.exs diff --git a/test/through_relationships_test.exs b/test/through_relationships_test.exs new file mode 100644 index 00000000..12fa7256 --- /dev/null +++ b/test/through_relationships_test.exs @@ -0,0 +1,302 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.Test.ThroughRelationshipsTest do + use AshPostgres.RepoCase, async: false + + require Ash.Query + + setup do + school_1 = create_school("School One") + school_2 = create_school("School Two") + + classroom_1 = create_classroom("Math 101", school_1.id) + classroom_2 = create_classroom("Science 101", school_1.id) + classroom_3 = create_classroom("History 101", school_2.id) + + teacher_1 = create_teacher("Mr. Smith") + teacher_2 = create_teacher("Ms. Johnson") + teacher_3 = create_teacher("Dr. Williams") + teacher_4 = create_teacher("Prof. Adams") + + student_1 = create_student("Alice", classroom_1.id) + student_2 = create_student("Bob", classroom_2.id) + + %{ + school_1: school_1, + school_2: school_2, + classroom_1: classroom_1, + classroom_2: classroom_2, + classroom_3: classroom_3, + teacher_1: teacher_1, + teacher_2: teacher_2, + teacher_3: teacher_3, + teacher_4: teacher_4, + student_1: student_1, + student_2: student_2 + } + end + + describe "has_many through relationships" do + test "loads teachers through classrooms -> classroom_teachers -> teacher", 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) + + school_with_teachers = Ash.load!(school_1, :teachers) + + teacher_names = + school_with_teachers.teachers + |> Enum.map(& &1.name) + |> Enum.sort() + + assert teacher_names == ["Dr. Williams", "Mr. Smith", "Ms. Johnson"] + end + + test "has_many through with no results", setup do + %{school_2: school_2} = setup + + school_with_teachers = Ash.load!(school_2, :teachers) + assert school_with_teachers.teachers == [] + end + + test "has_many through with empty intermediate results", setup do + %{school_1: school_1} = setup + + school_with_teachers = Ash.load!(school_1, :teachers) + assert school_with_teachers.teachers == [] + end + + test "3-hop path: school -> classrooms -> classroom_teachers -> teacher", setup do + %{school_1: school_1, classroom_1: classroom_1} = setup + %{teacher_1: teacher_1} = setup + + assign_teacher(classroom_1.id, teacher_1.id) + + school_with_teachers = Ash.load!(school_1, :teachers) + + assert length(school_with_teachers.teachers) == 1 + assert hd(school_with_teachers.teachers).name == "Mr. Smith" + end + end + + describe "many_to_many through atom list" do + test "retired_teachers loads only teachers with retired_at set", setup do + %{classroom_1: classroom_1} = setup + %{teacher_1: teacher_1, teacher_2: teacher_2} = setup + + assign_teacher(classroom_1.id, teacher_1.id) + assign_teacher(classroom_1.id, teacher_2.id) + + classroom_with_retired = Ash.load!(classroom_1, :retired_teachers) + + assert length(classroom_with_retired.retired_teachers) == 1 + assert hd(classroom_with_retired.retired_teachers).name == teacher_1.name + end + + test "retired_teachers returns empty when no teachers are retired", setup do + %{classroom_1: classroom_1} = setup + %{teacher_1: teacher_1} = setup + + assign_teacher(classroom_1.id, teacher_1.id) + + classroom_with_retired = Ash.load!(classroom_1, :retired_teachers) + assert classroom_with_retired.retired_teachers == [] + end + + test "teacher many_to_many classrooms via atom list through", setup do + %{classroom_1: classroom_1, classroom_2: classroom_2} = setup + %{teacher_1: teacher_1} = setup + + assign_teacher(classroom_1.id, teacher_1.id) + assign_teacher(classroom_2.id, teacher_1.id) + + teacher_with_classrooms = Ash.load!(teacher_1, :classrooms) + + classroom_names = + teacher_with_classrooms.classrooms + |> Enum.map(& &1.name) + |> Enum.sort() + + assert classroom_names == ["Math 101", "Science 101"] + end + end + + describe "has_one through relationships" do + test "active_teacher loads the non-retired teacher", setup do + %{classroom_1: classroom_1} = setup + %{teacher_1: teacher_1, teacher_2: teacher_2} = setup + + assign_teacher(classroom_1.id, teacher_1.id) + assign_teacher(classroom_1.id, teacher_2.id) + + classroom_loaded = Ash.load!(classroom_1, :active_teacher) + assert classroom_loaded.active_teacher.name == teacher_2.name + end + + test "student teacher through classroom active_teacher", setup do + %{classroom_1: classroom_1} = setup + %{teacher_1: teacher_1, teacher_2: teacher_2} = setup + %{student_1: student_1} = setup + + assign_teacher(classroom_1.id, teacher_1.id) + assign_teacher(classroom_1.id, teacher_2.id) + + student_with_teacher = Ash.load!(student_1, :teacher) + assert student_with_teacher.teacher.name == teacher_2.name + end + end + + describe "aggregates on through relationships" do + test "school teacher_count counts all teachers through path", setup do + %{school_1: school_1, classroom_1: classroom_1, classroom_2: classroom_2} = setup + %{teacher_1: teacher_1, teacher_2: teacher_2} = setup + + assign_teacher(classroom_1.id, teacher_1.id) + assign_teacher(classroom_2.id, teacher_1.id) + assign_teacher(classroom_2.id, teacher_2.id) + + school_with_agg = + AshPostgres.Test.Through.School + |> Ash.Query.filter(id == ^school_1.id) + |> Ash.Query.load([:classroom_count, :teacher_count, :teacher_count_via_path]) + |> Ash.read_one!() + + assert school_with_agg.classroom_count == 2 + assert school_with_agg.teacher_count == 3 + assert school_with_agg.teacher_count_via_path == 3 + end + + test "school retired_teacher_count counts only retired", setup do + %{school_1: school_1, classroom_1: classroom_1} = 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) + + school_with_agg = + AshPostgres.Test.Through.School + |> Ash.Query.filter(id == ^school_1.id) + |> Ash.Query.load(:retired_teacher_count) + |> Ash.Query.load(:active_teacher_count) + |> Ash.read_one!() + + assert school_with_agg.retired_teacher_count == 1 + assert school_with_agg.active_teacher_count == 1 + + assign_teacher(classroom_1.id, teacher_3.id) + + school_with_agg = + AshPostgres.Test.Through.School + |> Ash.Query.filter(id == ^school_1.id) + |> Ash.Query.load(:retired_teacher_count) + |> Ash.Query.load(:active_teacher_count) + |> Ash.read_one!() + + assert school_with_agg.retired_teacher_count == 2 + assert school_with_agg.active_teacher_count == 1 + end + + test "multiple schools with aggregates", setup do + %{school_1: school_1, school_2: school_2} = setup + %{classroom_1: classroom_1, classroom_2: classroom_2, classroom_3: classroom_3} = setup + + %{teacher_1: teacher_1, teacher_2: teacher_2, teacher_3: teacher_3, teacher_4: teacher_4} = + setup + + assign_teacher(classroom_1.id, teacher_1.id) + assign_teacher(classroom_1.id, teacher_2.id) + assign_teacher(classroom_2.id, teacher_3.id) + assign_teacher(classroom_3.id, teacher_2.id) + assign_teacher(classroom_3.id, teacher_3.id) + assign_teacher(classroom_3.id, teacher_4.id) + + [school_one, school_two] = + AshPostgres.Test.Through.School + |> Ash.Query.load([:classroom_count, :teacher_count, :teacher_count_via_path]) + |> Ash.Query.filter(id in [^school_1.id, ^school_2.id]) + |> Ash.Query.sort(:name) + |> Ash.read!() + + assert school_one.name == "School One" + assert school_one.classroom_count == 2 + assert school_one.teacher_count == 3 + assert school_one.teacher_count_via_path == 3 + + assert school_two.name == "School Two" + assert school_two.classroom_count == 1 + assert school_two.teacher_count == 3 + assert school_two.teacher_count_via_path == 3 + end + + test "students know their active teacher", 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, teacher_4: teacher_4} = + setup + + %{student_1: student_1, student_2: student_2} = setup + + assign_teacher(classroom_1.id, teacher_1.id) + assign_teacher(classroom_2.id, teacher_2.id) + + student_1 = Ash.load!(student_1, [:teacher, :retired_teacher_count]) + student_2 = Ash.load!(student_2, [:teacher, :retired_teacher_count]) + + assign_teacher(classroom_1.id, teacher_3.id) + assign_teacher(classroom_2.id, teacher_4.id) + + student_1 = Ash.load!(student_1, [:teacher, :retired_teacher_count]) + student_2 = Ash.load!(student_2, [:teacher, :retired_teacher_count]) + + assert student_1.teacher.name == teacher_3.name + assert student_1.retired_teacher_count == 1 + assert student_2.teacher.name == teacher_4.name + assert student_2.retired_teacher_count == 1 + + school_1 = Ash.load!(school_1, [:retired_teacher_count, :active_teacher_count]) + + assert school_1.retired_teacher_count == 2 + assert school_1.active_teacher_count == 2 + end + end + + defp create_school(name) do + AshPostgres.Test.Through.School + |> Ash.Changeset.for_create(:create, %{name: name}) + |> Ash.create!() + end + + defp create_classroom(name, school_id) do + AshPostgres.Test.Through.Classroom + |> Ash.Changeset.for_create(:create, %{name: name, school_id: school_id}) + |> Ash.create!() + end + + defp create_teacher(name) do + AshPostgres.Test.Through.Teacher + |> Ash.Changeset.for_create(:create, %{name: name}) + |> Ash.create!() + end + + defp create_student(name, classroom_id) do + AshPostgres.Test.Through.Student + |> Ash.Changeset.for_create(:create, %{name: name, classroom_id: classroom_id}) + |> Ash.create!() + end + + defp assign_teacher(classroom_id, teacher_id, opts \\ []) do + attrs = + %{classroom_id: classroom_id, teacher_id: teacher_id} + |> Map.merge(Map.new(opts)) + + AshPostgres.Test.Through.ClassroomTeacher + |> Ash.Changeset.for_create(:assign, attrs) + |> Ash.create!() + end +end From 3d4a30a7c86aab2e3a5544f747fd2056b4b597a4 Mon Sep 17 00:00:00 2001 From: ken-kost Date: Fri, 6 Feb 2026 13:28:22 +0100 Subject: [PATCH 4/5] Expand through resources to have policies --- ...0260206120932_add_public_to_classrooms.exs | 19 +++++++++++++++++++ test/support/resources/through/classroom.ex | 16 +++++++++++++++- .../resources/through/classroom_teacher.ex | 15 ++++++++++++++- test/support/resources/through/school.ex | 15 ++++++++++++++- test/support/resources/through/student.ex | 15 ++++++++++++++- test/support/resources/through/teacher.ex | 15 ++++++++++++++- 6 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 priv/test_repo/migrations/20260206120932_add_public_to_classrooms.exs diff --git a/priv/test_repo/migrations/20260206120932_add_public_to_classrooms.exs b/priv/test_repo/migrations/20260206120932_add_public_to_classrooms.exs new file mode 100644 index 00000000..230c30a0 --- /dev/null +++ b/priv/test_repo/migrations/20260206120932_add_public_to_classrooms.exs @@ -0,0 +1,19 @@ +defmodule AshPostgres.TestRepo.Migrations.AddPublicToClassrooms do + @moduledoc """ + Adds public column to classrooms for through relationship policy testing. + """ + + use Ecto.Migration + + def up do + alter table(:classrooms) do + add(:public, :boolean, default: true) + end + end + + def down do + alter table(:classrooms) do + remove(:public) + end + end +end diff --git a/test/support/resources/through/classroom.ex b/test/support/resources/through/classroom.ex index f60ff384..e2a2d4a2 100644 --- a/test/support/resources/through/classroom.ex +++ b/test/support/resources/through/classroom.ex @@ -6,16 +6,30 @@ defmodule AshPostgres.Test.Through.Classroom do @moduledoc false use Ash.Resource, domain: AshPostgres.Test.Domain, - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] postgres do table "classrooms" repo AshPostgres.TestRepo end + policies do + policy action_type(:read) do + authorize_if(expr(public == true)) + end + end + + field_policies do + field_policy :* do + authorize_if(always()) + end + end + attributes do uuid_primary_key(:id) attribute(:name, :string, public?: true) + attribute(:public, :boolean, public?: true, default: true) end actions do diff --git a/test/support/resources/through/classroom_teacher.ex b/test/support/resources/through/classroom_teacher.ex index fe6ea7db..99b4bc6e 100644 --- a/test/support/resources/through/classroom_teacher.ex +++ b/test/support/resources/through/classroom_teacher.ex @@ -6,10 +6,23 @@ defmodule AshPostgres.Test.Through.ClassroomTeacher do @moduledoc false use Ash.Resource, domain: AshPostgres.Test.Domain, - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] require Ash.Query + policies do + policy action_type(:read) do + authorize_if(expr(is_nil(retired_at))) + end + end + + field_policies do + field_policy :* do + authorize_if(always()) + end + end + postgres do table "classroom_teachers" repo AshPostgres.TestRepo diff --git a/test/support/resources/through/school.ex b/test/support/resources/through/school.ex index 71173bbe..34a6795f 100644 --- a/test/support/resources/through/school.ex +++ b/test/support/resources/through/school.ex @@ -6,13 +6,26 @@ defmodule AshPostgres.Test.Through.School do @moduledoc false use Ash.Resource, domain: AshPostgres.Test.Domain, - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] postgres do table "schools" repo AshPostgres.TestRepo end + policies do + policy action_type(:read) do + authorize_if(always()) + end + end + + field_policies do + field_policy :* do + authorize_if(always()) + end + end + attributes do uuid_primary_key(:id) attribute(:name, :string, public?: true) diff --git a/test/support/resources/through/student.ex b/test/support/resources/through/student.ex index ed526cd3..df27c606 100644 --- a/test/support/resources/through/student.ex +++ b/test/support/resources/through/student.ex @@ -6,7 +6,20 @@ defmodule AshPostgres.Test.Through.Student do @moduledoc false use Ash.Resource, domain: AshPostgres.Test.Domain, - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] + + policies do + policy action_type(:read) do + authorize_if(always()) + end + end + + field_policies do + field_policy :* do + authorize_if(always()) + end + end postgres do table "students" diff --git a/test/support/resources/through/teacher.ex b/test/support/resources/through/teacher.ex index 529a68c9..7da3b687 100644 --- a/test/support/resources/through/teacher.ex +++ b/test/support/resources/through/teacher.ex @@ -6,7 +6,20 @@ defmodule AshPostgres.Test.Through.Teacher do @moduledoc false use Ash.Resource, domain: AshPostgres.Test.Domain, - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] + + policies do + policy action_type(:read) do + authorize_if(always()) + end + end + + field_policies do + field_policy :* do + authorize_if(always()) + end + end postgres do table "teachers" From 632cc68240350e49e527d139a5db0f7b3950889e Mon Sep 17 00:00:00 2001 From: ken-kost Date: Fri, 6 Feb 2026 13:28:31 +0100 Subject: [PATCH 5/5] Add through tests testing policies --- test/through_relationships_test.exs | 49 +++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/test/through_relationships_test.exs b/test/through_relationships_test.exs index 12fa7256..e75632d2 100644 --- a/test/through_relationships_test.exs +++ b/test/through_relationships_test.exs @@ -266,15 +266,60 @@ defmodule AshPostgres.Test.ThroughRelationshipsTest do end end + describe "policy enforcement on through relationships" do + test "filtering on through relationships respects intermediate classroom policy", _setup do + school_1 = create_school("Policy Test School 1") + school_2 = create_school("Policy Test School 2") + classroom_public = create_classroom("Public Classroom", school_1.id, public: true) + classroom_private = create_classroom("Private Classroom", school_2.id, public: false) + teacher_public = create_teacher("Teacher Public") + teacher_private = create_teacher("Teacher Private") + + assign_teacher(classroom_public.id, teacher_public.id) + assign_teacher(classroom_private.id, teacher_private.id) + + filter = [teacher_public.name, teacher_private.name] + + assert [%{name: "Policy Test School 1"}] = + AshPostgres.Test.Through.School + |> Ash.Query.filter(%{teachers: %{name: %{in: ^filter}}}) + |> Ash.read!(authorize?: true) + end + + test "filtering on through relationships respects classroom_teacher policy (non-retired)", + _setup do + school_active = create_school("School With Active Teacher") + school_retired = create_school("School With Retired Teacher") + classroom_active = create_classroom("Active Classroom", school_active.id, public: true) + classroom_retired = create_classroom("Retired Classroom", school_retired.id, public: true) + teacher_active = create_teacher("Teacher Active") + teacher_retired = create_teacher("Teacher Retired") + + assign_teacher(classroom_active.id, teacher_active.id) + assign_teacher(classroom_retired.id, teacher_retired.id, retired_at: DateTime.utc_now()) + + filter = [teacher_active.name, teacher_retired.name] + + assert [%{name: "School With Active Teacher"}] = + AshPostgres.Test.Through.School + |> Ash.Query.filter(%{teachers: %{name: %{in: ^filter}}}) + |> Ash.read!(authorize?: true) + end + end + defp create_school(name) do AshPostgres.Test.Through.School |> Ash.Changeset.for_create(:create, %{name: name}) |> Ash.create!() end - defp create_classroom(name, school_id) do + defp create_classroom(name, school_id, opts \\ []) do + attrs = + %{name: name, school_id: school_id} + |> Map.merge(Map.new(opts)) + AshPostgres.Test.Through.Classroom - |> Ash.Changeset.for_create(:create, %{name: name, school_id: school_id}) + |> Ash.Changeset.for_create(:create, attrs) |> Ash.create!() end