From e285cdab16cea68ed83a37c8da535004661805d0 Mon Sep 17 00:00:00 2001 From: Jechol Lee Date: Sat, 7 Nov 2026 06:39:23 +0000 Subject: [PATCH] perf: prune relationship projections before query execution --- lib/data_layer.ex | 12 +++- mix.exs | 2 +- test/relationship_projection_test.exs | 88 +++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 test/relationship_projection_test.exs diff --git a/lib/data_layer.ex b/lib/data_layer.ex index f5e690ac..eb4b7d28 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -942,6 +942,7 @@ defmodule AshPostgres.DataLayer do @impl true def run_query(query, resource) do query = AshSql.Bindings.default_bindings(query, resource, AshPostgres.SqlImplementation) + query = AshSql.Query.Projection.prune(query) if AshPostgres.DataLayer.Info.polymorphic?(resource) && no_table?(query) do raise_table_error!(resource, :read) @@ -1165,7 +1166,7 @@ defmodule AshPostgres.DataLayer do AshSql.dynamic_repo(source_resource, AshPostgres.SqlImplementation, query) repo.one( - query, + AshSql.Query.Projection.prune(query), AshSql.repo_opts( repo, AshPostgres.SqlImplementation, @@ -1226,6 +1227,8 @@ defmodule AshPostgres.DataLayer do _ -> lateral_join_query end + lateral_join_query = AshSql.Query.Projection.prune(lateral_join_query) + results = repo.all( lateral_join_query, @@ -2630,7 +2633,12 @@ defmodule AshPostgres.DataLayer do with_savepoint(repo, query, fn -> {:ok, - repo.one(query) |> Enum.sort_by(&elem(&1, 0)) |> Enum.map(&elem(&1, 1)) |> Enum.reverse()} + query + |> AshSql.Query.Projection.prune() + |> repo.one() + |> Enum.sort_by(&elem(&1, 0)) + |> Enum.map(&elem(&1, 1)) + |> Enum.reverse()} end) rescue e -> diff --git a/mix.exs b/mix.exs index 585183a1..676f99ad 100644 --- a/mix.exs +++ b/mix.exs @@ -190,7 +190,7 @@ defmodule AshPostgres.MixProject do [ {:ash, ash_version("~> 3.37 and >= 3.37.1")}, {:spark, "~> 2.3 and >= 2.3.4"}, - {:ash_sql, ash_sql_version("~> 0.8 and >= 0.8.8")}, + {:ash_sql, ash_sql_version("~> 0.8 and >= 0.8.9")}, {:igniter, "~> 0.6 and >= 0.6.29", optional: true}, {:ecto_sql, "~> 3.13"}, {:ecto, "~> 3.13"}, diff --git a/test/relationship_projection_test.exs b/test/relationship_projection_test.exs new file mode 100644 index 00000000..4219008f --- /dev/null +++ b/test/relationship_projection_test.exs @@ -0,0 +1,88 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.RelationshipProjectionTest do + use AshPostgres.RepoCase, async: false + alias AshPostgres.Test.{Comment, Post} + require Ash.Query + require Ash.Sort + import Ash.Expr + + test "calculations, filtering and sorting keep the same latest related row" do + posts = + for title <- ["first", "second", "empty"], + do: Ash.create!(Ash.Changeset.for_create(Post, :create, %{title: title})) + + [first, second, empty] = posts + + for {post, title, likes, created_at} <- [ + {first, "old", 10, ~U[2026-01-01 00:00:00.000000Z]}, + {first, "new", 2, ~U[2026-01-02 00:00:00.000000Z]}, + {second, "other", 8, ~U[2026-01-02 00:00:00.000000Z]} + ] do + Comment + |> Ash.Changeset.for_create(:create, %{ + post_id: post.id, + title: title, + likes: likes, + created_at: created_at + }) + |> Ash.create!() + end + + ids = Enum.map(posts, & &1.id) + + query = + Post + |> Ash.Query.filter(id in ^ids) + |> Ash.Query.select([:id]) + |> Ash.Query.calculate(:latest_title, :string, expr(latest_comment.title)) + |> Ash.Query.calculate( + :latest_created_at, + :utc_datetime_usec, + expr(latest_comment.created_at) + ) + + original = query |> Ash.data_layer_query!() |> Map.fetch!(:query) + {before_sql, before_params} = TestRepo.to_sql(:all, original) + {after_sql, after_params} = TestRepo.to_sql(:all, AshSql.Query.Projection.prune(original)) + assert before_sql =~ ~s("edited_duration" AS "edited_duration") + refute after_sql =~ ~s("edited_duration" AS "edited_duration") + assert before_params == after_params + + rows = Map.new(Ash.read!(query), &{&1.id, &1.calculations}) + assert rows[first.id].latest_title == "new" + assert rows[second.id].latest_created_at == ~U[2026-01-02 00:00:00.000000Z] + assert rows[empty.id].latest_title == nil + + assert [%{id: id}] = query |> Ash.Query.filter(latest_comment.likes > 5) |> Ash.read!() + assert id == second.id + + sorted = + query + |> Ash.Query.sort([{Ash.Sort.expr_sort(latest_comment.likes), :desc_nils_last}]) + |> Ash.read!() + + assert Enum.map(sorted, & &1.id) == [second.id, first.id, empty.id] + end + + test "loading the whole relationship still includes fields unused by calculations" do + post = Post |> Ash.Changeset.for_create(:create, %{title: "post"}) |> Ash.create!() + + comment = + Comment + |> Ash.Changeset.for_create(:create, %{ + post_id: post.id, + title: "comment", + label: "label", + likes: 4 + }) + |> Ash.create!() + + assert %{latest_comment: %{id: id, label: "label", likes: 4}} = + Ash.load!(post, :latest_comment) + + assert id == comment.id + end +end