From 9d43bb072d008d92260af73b92ed596f895c11d2 Mon Sep 17 00:00:00 2001 From: Will Townsend Date: Tue, 21 Jul 2026 22:09:41 -0700 Subject: [PATCH 01/14] feat(aggregates): enable the grouped AshSQL strategy Enable AshSQLite to route supported aggregate operations through AshSQL's grouped planner. Implementation notes: - select `:grouped` in the SQL implementation; - advertise the supported aggregate kinds and relationship shapes; - delegate query return, filtering, calculations, and root aggregates to the shared AshSQL paths; - keep manual, parent-dependent, and no-attribute relationship shapes unsupported. The following commits add end-to-end coverage for each enabled path. --- lib/data_layer.ex | 67 +++++++++++++++++++++++++++++++-------- lib/sql_implementation.ex | 3 ++ 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/lib/data_layer.ex b/lib/data_layer.ex index ec44534..86c8a39 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -467,12 +467,17 @@ defmodule AshSqlite.DataLayer do false end + def can?(_, {:aggregate, :unrelated}), do: true + def can?(_, {:exists, :unrelated}), do: true + def can?(_, :boolean_filter), do: true - def can?(_, {:aggregate, _type}), do: false + def can?(_, {:aggregate, type}) + when type in [:count, :sum, :avg, :max, :min, :exists, :first, :list, :custom], + do: true - def can?(_, :aggregate_filter), do: false - def can?(_, :aggregate_sort), do: false + def can?(_, :aggregate_filter), do: true + def can?(_, :aggregate_sort), do: true def can?(_, :expression_calculation), do: true def can?(_, :expression_calculation_sort), do: true def can?(_, :create), do: true @@ -496,7 +501,30 @@ defmodule AshSqlite.DataLayer do def can?(_, {:filter_relationship, _}), do: true - def can?(_, {:aggregate_relationship, _}), do: false + def can?(_, {:aggregate_relationship, %{manual: {_, _}}}), do: false + + def can?(_, {:aggregate_relationship, %{type: :many_to_many} = relationship}) do + join_relationship = + Ash.Resource.Info.relationship(relationship.source, relationship.join_relationship) + + not is_nil(join_relationship) && + not AshSql.Aggregate.Grouped.relationship_filter_uses_parent?(relationship) && + not AshSql.Aggregate.Grouped.relationship_filter_uses_parent?(join_relationship) && + can?(relationship.source, {:join, relationship.through}) && + can?(relationship.through, {:join, relationship.destination}) + end + + def can?(_, {:aggregate_relationship, %{no_attributes?: true}}), do: false + + def can?(_, {:aggregate_relationship, relationship}) + when not is_nil(relationship.filter) do + not AshSql.Aggregate.Grouped.relationship_filter_uses_parent?(relationship) && + can?(relationship.source, {:join, relationship.destination}) + end + + def can?(resource, {:aggregate_relationship, relationship}) do + can?(resource, {:join, relationship.destination}) + end def can?(_, :timeout), do: true def can?(_, {:filter_expr, %Ash.Query.Function.StringJoin{}}), do: false @@ -560,6 +588,13 @@ defmodule AshSqlite.DataLayer do {:ok, from(row in query, offset: ^offset)} end + @impl true + def return_query(query, resource) do + query + |> AshSql.Bindings.default_bindings(resource, AshSqlite.SqlImplementation) + |> AshSql.Query.return_query(resource) + end + @impl true def run_aggregate_query(query, aggregates, resource) do AshSql.AggregateQuery.run_aggregate_query( @@ -2032,20 +2067,26 @@ defmodule AshSqlite.DataLayer do @impl true def filter(query, filter, _resource, opts \\ []) do - query - |> AshSql.Join.join_all_relationships(filter, opts) - |> case do - {:ok, query} -> - {:ok, AshSql.Filter.add_filter_expression(query, filter)} + AshSql.Filter.filter(query, filter, query.__ash_bindings__.resource, opts) + end - {:error, error} -> - {:error, error} - end + @impl true + def add_aggregates(query, aggregates, _resource) do + {:ok, + Map.update!(query, :__ash_bindings__, fn bindings -> + Map.put(bindings, :load_aggregates, aggregates) + end)} end @impl true def add_calculations(query, calculations, resource) do - AshSql.Calculation.add_calculations(query, calculations, resource, 0, true) + AshSql.Calculation.add_calculations( + query, + calculations, + resource, + query.__ash_bindings__.root_binding, + true + ) end @doc false diff --git a/lib/sql_implementation.ex b/lib/sql_implementation.ex index 597c992..20358de 100644 --- a/lib/sql_implementation.ex +++ b/lib/sql_implementation.ex @@ -9,6 +9,9 @@ defmodule AshSqlite.SqlImplementation do require Ecto.Query require Ash.Expr + @impl true + def aggregate_strategy(_resource), do: :grouped + @impl true def manual_relationship_function, do: :ash_sqlite_join From e974bcae3178feec2707b2489e8a80dfebdc403a Mon Sep 17 00:00:00 2001 From: Will Townsend Date: Tue, 21 Jul 2026 22:09:48 -0700 Subject: [PATCH 02/14] feat(aggregates): implement SQLite grouped lists Provide the SQLite expression used by AshSQL's grouped list planner. Implementation notes: - build lists with windowed `json_group_array`; - filter nil values inside the SQL aggregate when `include_nil?` is false; - leave window planning and result typing in AshSQL; - keep SQLite syntax and JSON representation inside the adapter. The acceptance suite covers ordering, nil inclusion, defaults, constrained values, relationship bounds, and calculation fields. --- lib/sql_implementation.ex | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lib/sql_implementation.ex b/lib/sql_implementation.ex index 20358de..0dbf163 100644 --- a/lib/sql_implementation.ex +++ b/lib/sql_implementation.ex @@ -12,6 +12,22 @@ defmodule AshSqlite.SqlImplementation do @impl true def aggregate_strategy(_resource), do: :grouped + @impl true + def grouped_list_aggregate(field, true) do + Ecto.Query.dynamic( + over(fragment("json_group_array(?)", ^field), :ash_sql_grouped_aggregate_window) + ) + end + + def grouped_list_aggregate(field, false) do + Ecto.Query.dynamic( + over( + fragment("json_group_array(?) FILTER (WHERE ? IS NOT NULL)", ^field, ^field), + :ash_sql_grouped_aggregate_window + ) + ) + end + @impl true def manual_relationship_function, do: :ash_sqlite_join From 9d76bccea96e766eebc97e1cc38f5bbeccc3be56 Mon Sep 17 00:00:00 2001 From: Will Townsend Date: Tue, 21 Jul 2026 22:09:55 -0700 Subject: [PATCH 03/14] feat(aggregates): add SQLite custom aggregates Add a small adapter API for custom Ecto aggregate expressions. Implementation notes: - define callbacks for the aggregate expression and result type; - let custom modules use SQLite-specific functions; - leave relationships, filters, windows, and result loading in the shared planner. The acceptance suite exercises custom aggregates in root queries and related aggregate loads. --- lib/custom_aggregate.ex | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 lib/custom_aggregate.ex diff --git a/lib/custom_aggregate.ex b/lib/custom_aggregate.ex new file mode 100644 index 0000000..7a92114 --- /dev/null +++ b/lib/custom_aggregate.ex @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.CustomAggregate do + @moduledoc """ + A custom aggregate implementation for Ecto queries against SQLite. + """ + + @doc """ + The dynamic expression to create the aggregate. + + The binding refers to the resource being aggregated. Use `as(^binding)` to + reference it. + + For example: + + Ecto.Query.dynamic( + [], + fragment("group_concat(?, ?)", field(as(^binding), ^opts[:field]), ^opts[:delimiter]) + ) + """ + @callback dynamic(opts :: Keyword.t(), binding :: integer) :: Ecto.Query.dynamic_expr() + + defmacro __using__(_) do + quote do + @behaviour AshSqlite.CustomAggregate + end + end +end From 11ef1857d60fe0a644c0a08a63ccc017065ae782 Mon Sep 17 00:00:00 2001 From: Will Townsend Date: Tue, 21 Jul 2026 22:10:17 -0700 Subject: [PATCH 04/14] test(aggregates): cover grouped aggregate behavior Add the main SQLite acceptance matrix for the grouped strategy. Coverage includes: - root and loaded scalar aggregates; - first, list, exists, and custom aggregates; - filtering, sorting, pagination, calculations, and join filters; - one-hop, unrelated, many-to-many, and multi-hop paths; - nil handling, defaults, distinct values, read actions, and stable unsupported errors. These tests verify the generated grouped and windowed SQL through the concrete SQLite adapter. --- test/aggregate_test.exs | 1121 ++++++++++++++++++++++++++++- test/calculation_test.exs | 2 +- test/support/resources/author.ex | 82 +++ test/support/resources/comment.ex | 10 + test/support/resources/post.ex | 224 ++++++ test/support/string_agg.ex | 19 + test/support/total_agg.ex | 18 + 7 files changed, 1474 insertions(+), 2 deletions(-) create mode 100644 test/support/string_agg.ex create mode 100644 test/support/total_agg.ex diff --git a/test/aggregate_test.exs b/test/aggregate_test.exs index 6593479..2d3e2fa 100644 --- a/test/aggregate_test.exs +++ b/test/aggregate_test.exs @@ -6,7 +6,7 @@ defmodule AshSqlite.AggregatesTest do use AshSqlite.RepoCase, async: false require Ash.Query - alias AshSqlite.Test.Post + alias AshSqlite.Test.{Author, Comment, Post, PostLink, Profile, Rating} test "a count with a filter returns the appropriate value" do Ash.Seed.seed!(%Post{title: "foo"}) @@ -21,6 +21,39 @@ defmodule AshSqlite.AggregatesTest do assert count == 2 end + test "query aggregate kinds work" do + create_post!("query aggregate b", %{score: 1}) + create_post!("query aggregate a", %{score: 3}) + create_post!("query aggregate c", %{score: 5}) + + assert %{ + count: 3, + sum_score: 9, + max_score: 5, + min_score: 1, + avg_score: 3.0, + first_title: "query aggregate a", + exists_any: true + } = + Ash.aggregate!(Post, [ + {:count, :count}, + {:sum_score, :sum, field: :score}, + {:max_score, :max, field: :score}, + {:min_score, :min, field: :score}, + {:avg_score, :avg, field: :score}, + {:first_title, :first, field: :title, query: [sort: [title: :asc]]}, + {:exists_any, :exists} + ]) + + assert Post + |> Ash.Query.filter(title == "query aggregate b") + |> Ash.exists?() + + refute Post + |> Ash.Query.filter(title == "missing query aggregate") + |> Ash.exists?() + end + test "pagination returns the count" do Ash.Seed.seed!(%Post{title: "foo"}) Ash.Seed.seed!(%Post{title: "foo"}) @@ -31,4 +64,1090 @@ defmodule AshSqlite.AggregatesTest do |> Ash.Query.for_read(:paginated) |> Ash.read!() end + + test "paginated reads can count and load scalar aggregates" do + create_post!("paged aggregate a") + page_post = create_post!("paged aggregate b") + create_post!("paged aggregate c") + + create_comment!(page_post, "first", 1) + create_comment!(page_post, "second", 1) + + assert %Ash.Page.Offset{ + count: 3, + limit: 1, + offset: 1, + results: [ + %Post{title: "paged aggregate b", count_of_comments: 2} + ] + } = + Post + |> Ash.Query.for_read(:paginated) + |> Ash.Query.load(:count_of_comments) + |> Ash.Query.sort(:title) + |> Ash.Query.page(offset: 1, limit: 1, count: true) + |> Ash.read!() + end + + test "related scalar aggregates can be loaded" do + post = create_post!("loaded") + empty_post = create_post!("empty") + + create_comment!(post, "match", 1) + create_comment!(post, "other", 4) + create_comment!(post, "other", 10) + + loaded_post = + post + |> Ash.load!([ + :count_of_comments, + :count_of_popular_comments, + :count_of_comments_called_match, + :sum_of_comment_likes, + :sum_of_comment_likes_called_match, + :avg_comment_likes, + :min_comment_likes, + :max_comment_likes, + :has_comment_called_match + ]) + + assert loaded_post.count_of_comments == 3 + assert loaded_post.count_of_popular_comments == 0 + assert loaded_post.count_of_comments_called_match == 1 + assert loaded_post.sum_of_comment_likes == 15 + assert loaded_post.sum_of_comment_likes_called_match == 1 + assert loaded_post.avg_comment_likes == 5.0 + assert loaded_post.min_comment_likes == 1 + assert loaded_post.max_comment_likes == 10 + assert loaded_post.has_comment_called_match == true + + empty_post = + empty_post + |> Ash.load!([ + :count_of_comments, + :sum_of_comment_likes, + :avg_comment_likes, + :has_comment_called_match + ]) + + assert empty_post.count_of_comments == 0 + assert empty_post.sum_of_comment_likes == nil + assert empty_post.avg_comment_likes == nil + assert empty_post.has_comment_called_match == false + + assert [ + %Post{title: "empty", count_of_comments: 0}, + %Post{title: "loaded", count_of_comments: 3} + ] = + Post + |> Ash.Query.load(:count_of_comments) + |> Ash.Query.sort(:title) + |> Ash.read!() + end + + test "fieldless count aggregates use SQL count star" do + {:ok, query} = + Post + |> Ash.Query.load(:count_of_comments) + |> Ash.Query.data_layer_query() + + {sql, _params} = Ecto.Adapters.SQL.to_sql(:all, AshSqlite.TestRepo, query) + + assert sql =~ "count(*)" + end + + test "relationship filters are applied to loaded aggregates" do + post = create_post!("relationship filter") + + create_comment!(post, "quiet", 1) + create_comment!(post, "popular", 11) + + assert %{count_of_popular_comments: 1} = + Ash.load!(post, :count_of_popular_comments) + end + + test "resource queries can sort by related aggregates" do + one_comment = create_post!("one comment") + two_comments = create_post!("two comments") + no_comments = create_post!("no comments") + + create_comment!(one_comment, "only", 1) + create_comment!(two_comments, "first", 1) + create_comment!(two_comments, "second", 1) + + assert [ + %Post{id: two_comments_id, count_of_comments: 2}, + %Post{id: one_comment_id, count_of_comments: 1}, + %Post{id: no_comments_id, count_of_comments: 0} + ] = + Post + |> Ash.Query.load(:count_of_comments) + |> Ash.Query.sort(count_of_comments: :desc) + |> Ash.read!() + + assert two_comments_id == two_comments.id + assert one_comment_id == one_comment.id + assert no_comments_id == no_comments.id + end + + test "aggregate sorting works with pagination and aggregate filters" do + one_comment = create_post!("one comment") + two_comments = create_post!("two comments") + three_comments = create_post!("three comments") + create_post!("no comments") + + create_comment!(one_comment, "only", 1) + create_comment!(two_comments, "first", 1) + create_comment!(two_comments, "second", 1) + create_comment!(three_comments, "first", 1) + create_comment!(three_comments, "second", 1) + create_comment!(three_comments, "third", 1) + + assert [%Post{id: two_comments_id, count_of_comments: 2}] = + Post + |> Ash.Query.load(:count_of_comments) + |> Ash.Query.filter(count_of_comments > 0) + |> Ash.Query.sort(count_of_comments: :desc) + |> Ash.Query.limit(1) + |> Ash.Query.offset(1) + |> Ash.read!() + + assert two_comments_id == two_comments.id + end + + test "resource queries can filter on related aggregates" do + post = create_post!("with comments") + create_comment!(post, "match", 1) + create_comment!(post, "other", 1) + + create_post!("without comments") + + assert [%Post{id: post_id, count_of_comments: 2}] = + Post + |> Ash.Query.load(:count_of_comments) + |> Ash.Query.filter(count_of_comments > 1) + |> Ash.read!() + + assert post_id == post.id + end + + test "resource queries can filter and sort on related aggregates without loading them" do + one_comment = create_post!("one unloaded comment") + two_comments = create_post!("two unloaded comments") + create_post!("no unloaded comments") + + create_comment!(one_comment, "only", 1) + create_comment!(two_comments, "first", 1) + create_comment!(two_comments, "second", 1) + + assert [%Post{id: two_comments_id}, %Post{id: one_comment_id}] = + Post + |> Ash.Query.filter(count_of_comments > 0) + |> Ash.Query.sort(count_of_comments: :desc) + |> Ash.read!() + + assert two_comments_id == two_comments.id + assert one_comment_id == one_comment.id + end + + test "list loads related aggregates" do + post = create_post!("list load") + empty_post = create_post!("list load empty") + + create_comment!(post, "first", 1) + create_comment!(post, "second", 1) + + assert [ + %Post{id: post_id, count_of_comments: 2}, + %Post{id: empty_post_id, count_of_comments: 0} + ] = Ash.load!([post, empty_post], :count_of_comments) + + assert post_id == post.id + assert empty_post_id == empty_post.id + end + + test "aggregate join filters are applied on one-hop relationships" do + post = create_post!("join filter") + + create_comment!(post, "match", 1) + create_comment!(post, "other", 1) + + assert %{count_of_comments_with_join_filter: 1} = + Ash.load!(post, :count_of_comments_with_join_filter) + end + + test "same-path aggregates can use different read action filters" do + post = create_post!("read action aggregate") + + create_comment!(post, "low", 1) + create_comment!(post, "high", 10) + + assert %{count_of_comments: 2, count_of_liked_comments: 1} = + Ash.load!(post, [:count_of_comments, :count_of_liked_comments]) + end + + test "aggregate filters can reference relationships" do + post = create_post!("related aggregate filter") + + create_comment!(post, "first", 1) + create_comment!(post, "second", 1) + + assert %{count_of_comments_with_related_filter: 2} = + Ash.load!(post, :count_of_comments_with_related_filter) + end + + test "aggregate filters can reference related exists expressions" do + post = create_post!("related aggregate exists filter") + + create_comment!(post, "first", 1) + create_comment!(post, "second", 1) + + assert %{count_of_comments_with_related_exists_filter: 2} = + Ash.load!(post, :count_of_comments_with_related_exists_filter) + end + + test "aggregate filters over filtered to-many relationship refs do not corrupt siblings" do + post = create_post!("filtered related aggregate filter") + popular_comment = create_comment!(post, "popular", 1) + unpopular_comment = create_comment!(post, "unpopular", 1) + + create_comment_rating!(popular_comment, 10) + create_comment_rating!(popular_comment, 11) + create_comment_rating!(unpopular_comment, 1) + + assert %{ + count_of_comments: 2, + sum_of_comment_likes: 2, + count_of_comments_with_popular_ratings: 1 + } = + Ash.load!(post, [ + :count_of_comments, + :sum_of_comment_likes, + :count_of_comments_with_popular_ratings + ]) + end + + test "fieldless count filters over to-many refs count distinct aggregate rows" do + post = create_post!("distinct related aggregate filter") + popular_comment = create_comment!(post, "popular", 1) + unpopular_comment = create_comment!(post, "unpopular", 1) + + create_comment_rating!(popular_comment, 10) + create_comment_rating!(popular_comment, 11) + create_comment_rating!(unpopular_comment, 1) + + assert %{count_of_comments_with_popular_ratings: 1} = + Ash.load!(post, :count_of_comments_with_popular_ratings) + end + + test "exists filters avoid to-many fanout for sum aggregates" do + post = create_post!("exists fanout aggregate filter") + popular_comment = create_comment!(post, "popular", 4) + unpopular_comment = create_comment!(post, "unpopular", 6) + + create_comment_rating!(popular_comment, 10) + create_comment_rating!(popular_comment, 11) + create_comment_rating!(unpopular_comment, 1) + + assert %{sum_of_comment_likes_with_popular_ratings_exists: 4} = + Ash.load!(post, :sum_of_comment_likes_with_popular_ratings_exists) + end + + test "fanout-prone aggregate filters return stable unsupported errors" do + post = create_post!("fanout aggregate filter") + comment = create_comment!(post, "popular", 1) + create_comment_rating!(comment, 10) + create_comment_rating!(comment, 11) + + assert_raise Ash.Error.Unknown, ~r/sum, avg, list, custom, or field-based count/, fn -> + Ash.load!(post, :sum_of_comment_likes_with_popular_ratings) + end + + assert_raise Ash.Error.Unknown, ~r/sum, avg, list, custom, or field-based count/, fn -> + Ash.load!(post, :avg_comment_likes_with_popular_ratings) + end + + assert_raise Ash.Error.Unknown, ~r/list, custom, or field-based count aggregates/, fn -> + Ash.load!(post, :comment_titles_with_popular_ratings) + end + + assert_raise Ash.Error.Unknown, ~r/list, custom, or field-based count aggregates/, fn -> + Ash.load!(post, :comment_titles_joined_with_popular_ratings) + end + + assert_raise Ash.Error.Unknown, ~r/list, custom, or field-based count aggregates/, fn -> + Ash.load!(post, :count_comment_titles_with_popular_ratings) + end + end + + test "aggregate filters using parent expressions return a stable unsupported error" do + post = create_post!("same") + create_comment!(post, "same", 1) + + assert_raise Ash.Error.Unknown, ~r/parent-dependent aggregate filters/, fn -> + Ash.load!(post, :count_of_comments_matching_post_title) + end + end + + test "parent-dependent aggregate join filters return a stable unsupported error" do + post = create_post!("parent join") + create_comment!(post, "parent join", 1) + + assert_raise Ash.Error.Unknown, ~r/parent-dependent join filters/, fn -> + Ash.load!(post, :count_of_comments_with_parent_join_filter) + end + end + + test "aggregate filters that reference aggregates return a stable unsupported error" do + post = create_post!("aggregate filter") + create_comment!(post, "comment", 1) + + assert_raise Ash.Error.Unknown, ~r/filters that reference other aggregates/, fn -> + Ash.load!(post, :count_of_comments_with_aggregate_filter) + end + end + + test "multi-hop aggregate relationships can be loaded through normal paths" do + post = create_post!("post multi-hop") + comment = create_comment!(post, "comment", 1) + create_comment_rating!(comment, 7) + + assert %{count_of_comment_ratings: 1} = + Ash.load!(post, :count_of_comment_ratings) + end + + test "first aggregates can be loaded" do + post = create_post!("first aggregate") + empty_post = create_post!("first aggregate empty") + + create_comment!(post, nil, 1) + create_comment!(post, "bbb", 1) + create_comment!(post, "aaa", 1) + create_comment!(post, "stuff", 1) + + loaded_post = + Ash.load!(post, [ + :first_comment, + :first_comment_nils_first, + :first_comment_nils_first_called_stuff, + :first_comment_nils_first_include_nil + ]) + + assert loaded_post.first_comment == "aaa" + assert loaded_post.first_comment_nils_first == "aaa" + assert loaded_post.first_comment_nils_first_called_stuff == "stuff" + assert loaded_post.first_comment_nils_first_include_nil == nil + + assert %{first_comment: nil} = Ash.load!(empty_post, :first_comment) + end + + test "first aggregates can be sorted and used over belongs_to and multi-hop paths" do + author = create_author!("Belongs", "To") + author_post = create_post_for_author!(author, "belongs to first") + + low = create_post!("low first") + high = create_post!("high first") + + create_comment!(low, "aaa", 1) + create_comment!(high, "zzz", 1) + + assert [ + %Post{id: low_id, first_comment: "aaa"}, + %Post{id: high_id, first_comment: "zzz"} + ] = + Post + |> Ash.Query.load(:first_comment) + |> Ash.Query.filter(count_of_comments > 0) + |> Ash.Query.sort(first_comment: :asc) + |> Ash.read!() + + assert low_id == low.id + assert high_id == high.id + + assert %{author_first_name: "Belongs"} = Ash.load!(author_post, :author_first_name) + + comment = create_comment!(high, "rated", 1) + create_comment_rating!(comment, 3) + create_comment_rating!(comment, 10) + + assert %{highest_rating: 10} = Ash.load!(high, :highest_rating) + end + + test "list aggregates can be loaded" do + post = create_post!("list aggregate") + empty_post = create_post!("list aggregate empty") + + first = create_comment!(post, "bbb", 1) + create_comment!(post, nil, 1) + create_comment!(post, "aaa", 7) + create_comment!(post, "aaa", 9) + + loaded_post = + Ash.load!(post, [ + :comment_titles, + :comment_titles_with_empty_default, + :comment_titles_with_string_default, + :comment_titles_with_nils, + :uniq_comment_titles, + :comment_titles_with_5_likes, + :comment_ids, + :comment_ids_with_default, + :comment_likes_with_integer_default + ]) + + assert loaded_post.comment_titles == ["aaa", "aaa", "bbb"] + assert loaded_post.comment_titles_with_empty_default == ["aaa", "aaa", "bbb"] + assert loaded_post.comment_titles_with_string_default == ["aaa", "aaa", "bbb"] + assert loaded_post.comment_titles_with_nils == ["aaa", "aaa", "bbb", nil] + assert loaded_post.uniq_comment_titles == ["aaa", "bbb"] + assert loaded_post.comment_titles_with_5_likes == ["aaa", "aaa"] + assert first.id in loaded_post.comment_ids + assert first.id in loaded_post.comment_ids_with_default + assert loaded_post.comment_likes_with_integer_default == [1, 1, 7, 9] + + assert %{ + comment_titles: [], + comment_titles_with_empty_default: [], + comment_titles_with_string_default: ["fallback"], + comment_ids_with_default: ["11111111-1111-1111-1111-111111111111"], + comment_likes_with_integer_default: [42] + } = + Ash.load!(empty_post, [ + :comment_titles, + :comment_titles_with_empty_default, + :comment_titles_with_string_default, + :comment_ids_with_default, + :comment_likes_with_integer_default + ]) + end + + test "custom aggregates can use sqlite-specific implementations" do + post = create_post!("custom aggregate") + + create_comment!(post, "aaa", 2) + create_comment!(post, "bbb", 3) + + assert %{comment_titles_joined: joined, total_comment_likes_custom: total} = + Ash.load!(post, [:comment_titles_joined, :total_comment_likes_custom]) + + assert joined |> String.split(",") |> Enum.sort() == ["aaa", "bbb"] + assert total == 5.0 + assert is_float(total) + end + + test "unrelated aggregates without parent filters can be loaded" do + first_author = create_author!("first", "author") + second_author = create_author!("second", "author") + + create_profile!("bbb") + create_profile!("aaa") + create_profile!(nil) + + create_post!("scored one", %{score: 2}) + create_post!("scored two", %{score: 3}) + + loaded_authors = + [first_author, second_author] + |> Ash.load!([ + :total_profiles, + :total_profiles_plus_one, + :total_post_score, + :avg_post_score, + :min_post_score, + :max_post_score, + :has_any_profile, + :first_profile_description, + :profile_descriptions, + :post_titles_joined + ]) + + assert [ + %Author{ + id: first_author_id, + total_profiles: 3, + total_profiles_plus_one: 4, + total_post_score: 5, + avg_post_score: 2.5, + min_post_score: 2, + max_post_score: 3, + has_any_profile: true, + first_profile_description: "aaa", + profile_descriptions: ["aaa", "bbb"] + } = loaded_first_author, + %Author{ + id: second_author_id, + total_profiles: 3, + total_profiles_plus_one: 4, + total_post_score: 5, + avg_post_score: 2.5, + min_post_score: 2, + max_post_score: 3, + has_any_profile: true, + first_profile_description: "aaa", + profile_descriptions: ["aaa", "bbb"] + } = loaded_second_author + ] = loaded_authors + + assert first_author_id == first_author.id + assert second_author_id == second_author.id + + assert loaded_first_author.post_titles_joined |> String.split(",") |> Enum.sort() == [ + "scored one", + "scored two" + ] + + assert loaded_second_author.post_titles_joined |> String.split(",") |> Enum.sort() == [ + "scored one", + "scored two" + ] + end + + test "unsupported aggregate relationship shapes return stable errors" do + manual_relationship = Ash.Resource.Info.relationship(Post, :comments_containing_title) + no_attributes_relationship = Ash.Resource.Info.relationship(Post, :posts_with_matching_title) + + parent_filter_relationship = + Ash.Resource.Info.relationship(Post, :comments_matching_post_title) + + refute AshSqlite.DataLayer.can?(Post, {:aggregate_relationship, manual_relationship}) + refute AshSqlite.DataLayer.can?(Post, {:aggregate_relationship, no_attributes_relationship}) + refute AshSqlite.DataLayer.can?(Post, {:aggregate_relationship, parent_filter_relationship}) + end + + test "parent-dependent unrelated aggregate filters return a stable unsupported error" do + author = create_author!("parent", "unrelated") + create_profile!("parent") + + assert_raise Ash.Error.Unknown, ~r/parent-dependent aggregate filters/, fn -> + Ash.load!(author, :profiles_matching_first_name) + end + end + + test "calculations can reference related aggregates" do + post = create_post!("with aggregate calculation", %{score: 3}) + empty_post = create_post!("without aggregate calculation", %{score: 7}) + + create_comment!(post, "first", 4) + create_comment!(post, "second", 6) + + assert [ + %Post{ + id: post_id, + has_comments: true, + comment_likes_with_score: 13 + }, + %Post{ + id: empty_post_id, + has_comments: false, + comment_likes_with_score: 7 + } + ] = + Post + |> Ash.Query.load([:has_comments, :comment_likes_with_score]) + |> Ash.Query.sort(comment_likes_with_score: :desc) + |> Ash.read!() + + assert post_id == post.id + assert empty_post_id == empty_post.id + end + + test "many_to_many scalar aggregates can be loaded" do + source = create_post!("source", %{score: 5}) + match = create_post!("match", %{score: 2}) + other = create_post!("other", %{score: 6}) + archived = create_post!("archived", %{score: 20}) + empty = create_post!("empty", %{score: 1}) + + link_posts!(source, [match, other]) + create_post_link!(source, archived, :archived) + + loaded_source = + Ash.load!(source, [ + :count_of_linked_posts, + :sum_of_linked_post_scores, + :avg_linked_post_score, + :min_linked_post_score, + :max_linked_post_score, + :has_linked_post_called_match + ]) + + assert loaded_source.count_of_linked_posts == 2 + assert loaded_source.sum_of_linked_post_scores == 8 + assert loaded_source.avg_linked_post_score == 4.0 + assert loaded_source.min_linked_post_score == 2 + assert loaded_source.max_linked_post_score == 6 + assert loaded_source.has_linked_post_called_match == true + + loaded_empty = + Ash.load!(empty, [ + :count_of_linked_posts, + :sum_of_linked_post_scores, + :avg_linked_post_score, + :has_linked_post_called_match + ]) + + assert loaded_empty.count_of_linked_posts == 0 + assert loaded_empty.sum_of_linked_post_scores == nil + assert loaded_empty.avg_linked_post_score == nil + assert loaded_empty.has_linked_post_called_match == false + end + + test "many_to_many aggregates with filters that require joins can be loaded" do + source = create_post!("source") + author = create_author!("John", "Doe") + linked = create_post_for_author!(author, "linked") + + link_posts!(source, [linked]) + + assert %{count_of_linked_posts_with_author: 1} = + Ash.load!(source, :count_of_linked_posts_with_author) + end + + test "many_to_many aggregate filters that require joins work in parent queries" do + first_source = create_post!("first source") + second_source = create_post!("second source") + create_post!("no links") + + author = create_author!("Jane", "Doe") + linked_with_author = create_post_for_author!(author, "linked with author") + linked_without_author = create_post!("linked without author") + + link_posts!(first_source, [linked_with_author, linked_without_author]) + link_posts!(second_source, [linked_with_author]) + + assert [ + %Post{id: first_source_id, count_of_linked_posts_with_author: 1}, + %Post{id: second_source_id, count_of_linked_posts_with_author: 1} + ] = + Post + |> Ash.Query.load(:count_of_linked_posts_with_author) + |> Ash.Query.filter(count_of_linked_posts_with_author > 0) + |> Ash.Query.sort(title: :asc) + |> Ash.read!() + + assert first_source_id == first_source.id + assert second_source_id == second_source.id + end + + test "many_to_many first and list aggregates can be loaded" do + source = create_post!("m2m window source") + empty = create_post!("m2m window empty") + first = create_post!("bbb") + second = create_post!("ccc") + archived = create_post!("aaa") + + link_posts!(source, [second, first]) + create_post_link!(source, archived, :archived) + + assert %{ + first_linked_post_title: "bbb", + linked_post_titles: ["bbb", "ccc"] + } = + Ash.load!(source, [ + :first_linked_post_title, + :linked_post_titles + ]) + + assert %{ + first_linked_post_title: nil, + linked_post_titles: [] + } = + Ash.load!(empty, [ + :first_linked_post_title, + :linked_post_titles + ]) + end + + test "many_to_many first and list aggregates with joined filters can be loaded" do + source = create_post!("m2m joined window source") + author = create_author!("Window", "Author") + without_author = create_post!("aaa") + with_author = create_post_for_author!(author, "bbb") + with_author_later = create_post_for_author!(author, "ccc") + + link_posts!(source, [without_author, with_author_later, with_author]) + + assert %{ + first_linked_post_title_with_author: "bbb", + linked_post_titles_with_author: ["bbb", "ccc"], + first_linked_post_title_with_author_join_filter: "bbb", + linked_post_titles_with_author_join_filter: ["bbb", "ccc"] + } = + Ash.load!(source, [ + :first_linked_post_title_with_author, + :linked_post_titles_with_author, + :first_linked_post_title_with_author_join_filter, + :linked_post_titles_with_author_join_filter + ]) + end + + test "many_to_many custom aggregates can be loaded" do + source = create_post!("m2m custom source") + empty = create_post!("m2m custom empty") + first = create_post!("aaa") + second = create_post!("bbb") + archived = create_post!("ccc") + + link_posts!(source, [second, first]) + create_post_link!(source, archived, :archived) + + assert %{linked_post_titles_joined: joined} = + Ash.load!(source, :linked_post_titles_joined) + + assert joined |> String.split(",") |> Enum.sort() == ["aaa", "bbb"] + + assert %{linked_post_titles_joined: nil} = + Ash.load!(empty, :linked_post_titles_joined) + end + + test "many_to_many aggregates can be filtered, sorted and used in calculations" do + one_link = create_post!("one link", %{score: 1}) + two_links = create_post!("two links", %{score: 2}) + no_links = create_post!("no links", %{score: 3}) + + linked_a = create_post!("linked a", %{score: 4}) + linked_b = create_post!("linked b", %{score: 5}) + + link_posts!(one_link, [linked_a]) + link_posts!(two_links, [linked_a, linked_b]) + + assert [ + %Post{ + id: two_links_id, + count_of_linked_posts: 2, + linked_post_score_with_score: 11 + }, + %Post{ + id: one_link_id, + count_of_linked_posts: 1, + linked_post_score_with_score: 5 + } + ] = + Post + |> Ash.Query.load([ + :count_of_linked_posts, + :linked_post_score_with_score + ]) + |> Ash.Query.filter(count_of_linked_posts > 0) + |> Ash.Query.sort(count_of_linked_posts: :desc) + |> Ash.read!() + + assert two_links_id == two_links.id + assert one_link_id == one_link.id + + assert %{linked_post_score_with_score: 3} = + Ash.load!(no_links, :linked_post_score_with_score) + end + + test "aggregate join filters are applied on many_to_many relationships" do + source = create_post!("m2m join filter source") + match = create_post!("match") + other = create_post!("other") + + link_posts!(source, [match, other]) + + assert %{count_of_linked_posts_with_join_filter: 1} = + Ash.load!(source, :count_of_linked_posts_with_join_filter) + end + + test "multi-hop scalar aggregates can be loaded" do + author = create_author!("multi", "hop") + empty_author = create_author!("empty", "author") + + first_post = create_post_for_author!(author, "first post") + second_post = create_post_for_author!(author, "second post") + + create_comment!(first_post, "match", 1) + create_comment!(first_post, "other", 4) + create_comment!(second_post, "other", 10) + + loaded_author = + Ash.load!(author, [ + :count_of_comments_through_posts, + :sum_of_comment_likes_through_posts, + :avg_comment_likes_through_posts, + :min_comment_likes_through_posts, + :max_comment_likes_through_posts, + :has_comment_called_match_through_posts + ]) + + assert loaded_author.count_of_comments_through_posts == 3 + assert loaded_author.sum_of_comment_likes_through_posts == 15 + assert loaded_author.avg_comment_likes_through_posts == 5.0 + assert loaded_author.min_comment_likes_through_posts == 1 + assert loaded_author.max_comment_likes_through_posts == 10 + assert loaded_author.has_comment_called_match_through_posts == true + + loaded_empty = + Ash.load!(empty_author, [ + :count_of_comments_through_posts, + :sum_of_comment_likes_through_posts, + :avg_comment_likes_through_posts, + :has_comment_called_match_through_posts + ]) + + assert loaded_empty.count_of_comments_through_posts == 0 + assert loaded_empty.sum_of_comment_likes_through_posts == nil + assert loaded_empty.avg_comment_likes_through_posts == nil + assert loaded_empty.has_comment_called_match_through_posts == false + end + + test "multi-hop list and custom aggregates can be loaded" do + author = create_author!("multi", "list") + empty_author = create_author!("multi", "list empty") + + first_post = create_post_for_author!(author, "first post") + second_post = create_post_for_author!(author, "second post") + + create_comment!(first_post, "bbb", 1) + create_comment!(second_post, "aaa", 1) + + assert %{ + comment_titles_through_posts: ["aaa", "bbb"], + comment_titles_joined_through_posts: joined + } = + Ash.load!(author, [ + :comment_titles_through_posts, + :comment_titles_joined_through_posts + ]) + + assert joined |> String.split(",") |> Enum.sort() == ["aaa", "bbb"] + + assert %{ + comment_titles_through_posts: [], + comment_titles_joined_through_posts: nil + } = + Ash.load!(empty_author, [ + :comment_titles_through_posts, + :comment_titles_joined_through_posts + ]) + end + + test "multi-hop aggregates can be filtered, sorted and used in calculations" do + one_comment = create_author!("one", "comment") + two_comments = create_author!("two", "comments") + no_comments = create_author!("no", "comments") + + one_post = create_post_for_author!(one_comment, "one post") + two_post = create_post_for_author!(two_comments, "two post") + + create_comment!(one_post, "only", 4) + create_comment!(two_post, "first", 5) + create_comment!(two_post, "second", 6) + + assert [ + %Author{ + id: two_comments_id, + count_of_comments_through_posts: 2, + comment_likes_through_posts_plus_one: 12 + }, + %Author{ + id: one_comment_id, + count_of_comments_through_posts: 1, + comment_likes_through_posts_plus_one: 5 + } + ] = + Author + |> Ash.Query.load([ + :count_of_comments_through_posts, + :comment_likes_through_posts_plus_one + ]) + |> Ash.Query.filter(count_of_comments_through_posts > 0) + |> Ash.Query.sort(count_of_comments_through_posts: :desc) + |> Ash.read!() + + assert two_comments_id == two_comments.id + assert one_comment_id == one_comment.id + + assert %{comment_likes_through_posts_plus_one: 1} = + Ash.load!(no_comments, :comment_likes_through_posts_plus_one) + end + + test "aggregate join filters are applied on multi-hop relationships" do + author = create_author!("multi", "join filter") + public_post = create_post_for_author!(author, "public post", %{public: true}) + private_post = create_post_for_author!(author, "private post", %{public: false}) + + create_comment!(public_post, "match", 1) + create_comment!(public_post, "other", 1) + create_comment!(private_post, "match", 1) + + loaded_author = + Ash.load!(author, [ + :count_of_comments_on_public_posts, + :count_of_comments_called_match_with_join_filter + ]) + + assert loaded_author.count_of_comments_on_public_posts == 2 + assert loaded_author.count_of_comments_called_match_with_join_filter == 2 + end + + test "intermediate read action filters are applied on multi-hop aggregates" do + author = create_author!("multi", "read action") + public_post = create_post_for_author!(author, "public action post", %{public: true}) + private_post = create_post_for_author!(author, "private action post", %{public: false}) + + create_comment!(public_post, "public", 1) + create_comment!(private_post, "private", 1) + + assert %{count_of_comments_through_public_posts: 1} = + Ash.load!(author, :count_of_comments_through_public_posts) + end + + test "multi-hop scalar aggregates ending in many_to_many relationships can be loaded" do + author = create_author!("multi", "m2m") + empty_author = create_author!("empty", "m2m") + + public_post = create_post_for_author!(author, "public post", %{public: true}) + private_post = create_post_for_author!(author, "private post", %{public: false}) + + match = create_post!("match", %{score: 2}) + other = create_post!("other", %{score: 6}) + private = create_post!("private", %{score: 10}) + archived = create_post!("archived", %{score: 20}) + + link_posts!(public_post, [match, other]) + link_posts!(private_post, [private]) + create_post_link!(private_post, archived, :archived) + + loaded_author = + Ash.load!(author, [ + :count_of_linked_posts_through_posts, + :sum_of_linked_post_scores_through_posts, + :avg_linked_post_score_through_posts, + :min_linked_post_score_through_posts, + :max_linked_post_score_through_posts, + :has_linked_post_called_match_through_posts + ]) + + assert loaded_author.count_of_linked_posts_through_posts == 3 + assert loaded_author.sum_of_linked_post_scores_through_posts == 18 + assert loaded_author.avg_linked_post_score_through_posts == 6.0 + assert loaded_author.min_linked_post_score_through_posts == 2 + assert loaded_author.max_linked_post_score_through_posts == 10 + assert loaded_author.has_linked_post_called_match_through_posts == true + + loaded_empty = + Ash.load!(empty_author, [ + :count_of_linked_posts_through_posts, + :sum_of_linked_post_scores_through_posts, + :avg_linked_post_score_through_posts, + :has_linked_post_called_match_through_posts + ]) + + assert loaded_empty.count_of_linked_posts_through_posts == 0 + assert loaded_empty.sum_of_linked_post_scores_through_posts == nil + assert loaded_empty.avg_linked_post_score_through_posts == nil + assert loaded_empty.has_linked_post_called_match_through_posts == false + end + + test "multi-hop many_to_many scalar aggregates work in parent queries" do + one_link = create_author!("one", "m2m") + two_links = create_author!("two", "m2m") + create_author!("none", "m2m") + + one_post = create_post_for_author!(one_link, "one post") + two_post = create_post_for_author!(two_links, "two post") + + linked_a = create_post!("linked a", %{score: 4}) + linked_b = create_post!("linked b", %{score: 5}) + + link_posts!(one_post, [linked_a]) + link_posts!(two_post, [linked_a, linked_b]) + + assert [ + %Author{ + id: two_links_id, + count_of_linked_posts_through_posts: 2, + linked_post_score_through_posts_plus_one: 10 + }, + %Author{ + id: one_link_id, + count_of_linked_posts_through_posts: 1, + linked_post_score_through_posts_plus_one: 5 + } + ] = + Author + |> Ash.Query.load([ + :count_of_linked_posts_through_posts, + :linked_post_score_through_posts_plus_one + ]) + |> Ash.Query.filter(count_of_linked_posts_through_posts > 0) + |> Ash.Query.sort(count_of_linked_posts_through_posts: :desc) + |> Ash.read!() + + assert two_links_id == two_links.id + assert one_link_id == one_link.id + end + + test "unsupported multi-hop many_to_many aggregate shapes return stable errors" do + author = create_author!("multi", "m2m unsupported") + post = create_post_for_author!(author, "post") + linked_post = create_post!("linked") + + link_posts!(post, [linked_post]) + + assert_raise Ash.Error.Unknown, ~r/multi-hop paths that include many_to_many/, fn -> + Ash.load!(post, :count_of_comments_through_linked_posts) + end + + assert_raise Ash.Error.Unknown, ~r/multi-hop paths that include many_to_many/, fn -> + Ash.load!(author, :linked_post_titles_through_posts) + end + end + + defp create_post!(title, attrs \\ %{}) do + Post + |> Ash.Changeset.for_create(:create, Map.put(attrs, :title, title)) + |> Ash.create!() + end + + defp create_author!(first_name, last_name) do + Author + |> Ash.Changeset.for_create(:create, %{first_name: first_name, last_name: last_name}) + |> Ash.create!() + end + + defp create_post_for_author!(author, title, attrs \\ %{}) do + Post + |> Ash.Changeset.for_create(:create, Map.put(attrs, :title, title)) + |> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove) + |> Ash.create!() + end + + defp create_profile!(description) do + Profile + |> Ash.Changeset.for_create(:create, %{description: description}) + |> Ash.create!() + end + + defp create_comment!(post, title, likes, attrs \\ %{}) do + Comment + |> Ash.Changeset.for_create(:create, Map.merge(attrs, %{title: title, likes: likes})) + |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) + |> Ash.create!() + end + + defp create_comment_rating!(comment, score) do + Rating + |> Ash.Changeset.for_create(:create, %{score: score, resource_id: comment.id}) + |> Ash.Changeset.set_context(%{data_layer: %{table: "comment_ratings"}}) + |> Ash.create!() + end + + defp link_posts!(source, destinations) do + source + |> Ash.Changeset.new() + |> Ash.Changeset.manage_relationship(:linked_posts, destinations, type: :append_and_remove) + |> Ash.update!() + end + + defp create_post_link!(source, destination, state) do + PostLink + |> Ash.Changeset.new() + |> Ash.Changeset.change_attribute(:state, state) + |> Ash.Changeset.manage_relationship(:source_post, source, type: :append) + |> Ash.Changeset.manage_relationship(:destination_post, destination, type: :append) + |> Ash.create!() + end end diff --git a/test/calculation_test.exs b/test/calculation_test.exs index 73fe2ab..2d10ec7 100644 --- a/test/calculation_test.exs +++ b/test/calculation_test.exs @@ -402,7 +402,7 @@ defmodule AshSqlite.CalculationTest do |> Ash.create!() end) - assert_raise Ash.Error.Invalid, ~r/does not support using aggregates/, fn -> + assert_raise Ash.Error.Unknown, ~r/only supports loading related/, fn -> Ash.load!(author, :post_titles) end end diff --git a/test/support/resources/author.ex b/test/support/resources/author.ex index fc83280..ad6c30c 100644 --- a/test/support/resources/author.ex +++ b/test/support/resources/author.ex @@ -29,6 +29,74 @@ defmodule AshSqlite.Test.Author do relationships do has_one(:profile, AshSqlite.Test.Profile, public?: true) has_many(:posts, AshSqlite.Test.Post, public?: true) + has_many(:public_posts, AshSqlite.Test.Post, public?: true, read_action: :public) + end + + aggregates do + count(:count_of_comments_through_posts, [:posts, :comments]) + count(:count_of_comments_through_public_posts, [:public_posts, :comments]) + count(:count_of_linked_posts_through_posts, [:posts, :linked_posts]) + count(:total_profiles, AshSqlite.Test.Profile) + sum(:total_post_score, AshSqlite.Test.Post, :score) + avg(:avg_post_score, AshSqlite.Test.Post, :score) + min(:min_post_score, AshSqlite.Test.Post, :score) + max(:max_post_score, AshSqlite.Test.Post, :score) + sum(:sum_of_comment_likes_through_posts, [:posts, :comments], :likes) + avg(:avg_comment_likes_through_posts, [:posts, :comments], :likes) + min(:min_comment_likes_through_posts, [:posts, :comments], :likes) + max(:max_comment_likes_through_posts, [:posts, :comments], :likes) + sum(:sum_of_linked_post_scores_through_posts, [:posts, :linked_posts], :score) + avg(:avg_linked_post_score_through_posts, [:posts, :linked_posts], :score) + min(:min_linked_post_score_through_posts, [:posts, :linked_posts], :score) + max(:max_linked_post_score_through_posts, [:posts, :linked_posts], :score) + + count :count_of_comments_on_public_posts, [:posts, :comments] do + join_filter(:posts, expr(public == true)) + end + + count :count_of_comments_called_match_with_join_filter, [:posts, :comments] do + join_filter([:posts, :comments], expr(title == "match")) + end + + exists :has_comment_called_match_through_posts, [:posts, :comments] do + filter(expr(title == "match")) + end + + exists :has_linked_post_called_match_through_posts, [:posts, :linked_posts] do + filter(expr(title == "match")) + end + + exists :has_any_profile, AshSqlite.Test.Profile do + filter(expr(not is_nil(description))) + end + + count :profiles_matching_first_name, AshSqlite.Test.Profile do + filter(expr(description == parent(first_name))) + end + + first :first_profile_description, AshSqlite.Test.Profile, :description do + sort(description: :asc_nils_last) + end + + list :profile_descriptions, AshSqlite.Test.Profile, :description do + sort(description: :asc_nils_last) + end + + list :comment_titles_through_posts, [:posts, :comments], :title do + sort(title: :asc_nils_last) + end + + list :linked_post_titles_through_posts, [:posts, :linked_posts], :title do + sort(title: :asc_nils_last) + end + + custom(:post_titles_joined, AshSqlite.Test.Post, :string) do + implementation({AshSqlite.Test.StringAgg, field: :title, delimiter: ","}) + end + + custom(:comment_titles_joined_through_posts, [:posts, :comments], :string) do + implementation({AshSqlite.Test.StringAgg, field: :title, delimiter: ","}) + end end calculations do @@ -76,5 +144,19 @@ defmodule AshSqlite.Test.Author do end calculate(:post_titles, {:array, :string}, expr(list(posts, field: :title))) + + calculate( + :comment_likes_through_posts_plus_one, + :integer, + expr((sum_of_comment_likes_through_posts || 0) + 1) + ) + + calculate( + :linked_post_score_through_posts_plus_one, + :integer, + expr((sum_of_linked_post_scores_through_posts || 0) + 1) + ) + + calculate(:total_profiles_plus_one, :integer, expr(total_profiles + 1)) end end diff --git a/test/support/resources/comment.ex b/test/support/resources/comment.ex index ca2dc6b..25374ae 100644 --- a/test/support/resources/comment.ex +++ b/test/support/resources/comment.ex @@ -31,6 +31,12 @@ defmodule AshSqlite.Test.Comment do default_accept(:*) defaults([:read, :update, :destroy]) + read :liked do + filter(expr(likes > 5)) + end + + read(:public) + create :create do primary?(true) argument(:rating, :map) @@ -64,4 +70,8 @@ defmodule AshSqlite.Test.Comment do filter: expr(score > 5) ) end + + aggregates do + count(:count_of_ratings, :ratings) + end end diff --git a/test/support/resources/post.ex b/test/support/resources/post.ex index 383ad21..ea01a87 100644 --- a/test/support/resources/post.ex +++ b/test/support/resources/post.ex @@ -43,6 +43,10 @@ defmodule AshSqlite.Test.Post do pagination(offset?: true, required?: true) end + read :public do + filter(expr(public == true)) + end + create :create do primary?(true) argument(:rating, :map) @@ -172,9 +176,229 @@ defmodule AshSqlite.Test.Post do validate(attribute_does_not_equal(:title, "not allowed")) end + aggregates do + count(:count_of_comments, :comments) + count(:count_of_popular_comments, :popular_comments) + count(:count_of_linked_posts, :linked_posts) + count(:count_of_comments_through_linked_posts, [:linked_posts, :comments]) + count(:count_of_liked_comments, :comments, read_action: :liked) + count(:count_of_comment_ratings, [:comments, :ratings]) + sum(:sum_of_comment_likes, :comments, :likes) + sum(:sum_of_comment_likes_called_match, :comments, :likes, filter: expr(title == "match")) + + sum(:sum_of_comment_likes_with_popular_ratings, :comments, :likes) do + filter(expr(not is_nil(popular_ratings.id))) + end + + sum(:sum_of_comment_likes_with_popular_ratings_exists, :comments, :likes) do + filter(expr(exists(popular_ratings, score > 5))) + end + + sum(:sum_of_linked_post_scores, :linked_posts, :score) + avg(:avg_comment_likes, :comments, :likes) + + avg(:avg_comment_likes_with_popular_ratings, :comments, :likes) do + filter(expr(not is_nil(popular_ratings.id))) + end + + avg(:avg_linked_post_score, :linked_posts, :score) + min(:min_comment_likes, :comments, :likes) + min(:min_linked_post_score, :linked_posts, :score) + max(:max_comment_likes, :comments, :likes) + max(:max_linked_post_score, :linked_posts, :score) + + first :first_comment, :comments, :title do + sort(title: :asc_nils_last) + end + + first :first_comment_nils_first, :comments, :title do + sort(title: :asc_nils_first) + end + + first :first_comment_nils_first_called_stuff, :comments, :title do + sort(title: :asc_nils_first) + filter(expr(title == "stuff")) + end + + first :first_comment_nils_first_include_nil, :comments, :title do + include_nil?(true) + sort(title: :asc_nils_first) + end + + first :last_comment, :comments, :title do + sort(title: :desc) + end + + first :latest_comment_created_at, :comments, :created_at do + sort(created_at: :desc) + end + + first :highest_rating, [:comments, :ratings], :score do + sort(score: :desc) + end + + first(:author_first_name, :author, :first_name) + + first :first_linked_post_title, :linked_posts, :title do + sort(title: :asc_nils_last) + end + + first :first_linked_post_title_with_author, :linked_posts, :title do + sort(title: :asc_nils_last) + filter(expr(not is_nil(author.id))) + end + + first :first_linked_post_title_with_author_join_filter, :linked_posts, :title do + sort(title: :asc_nils_last) + join_filter(:linked_posts, expr(not is_nil(author.id))) + end + + list :comment_titles, :comments, :title do + sort(title: :asc_nils_last) + end + + list :comment_titles_with_empty_default, :comments, :title do + default([]) + sort(title: :asc_nils_last) + end + + list :comment_titles_with_string_default, :comments, :title do + default(["fallback"]) + sort(title: :asc_nils_last) + end + + list :comment_titles_with_nils, :comments, :title do + sort(title: :asc_nils_last) + include_nil?(true) + end + + list :uniq_comment_titles, :comments, :title do + uniq?(true) + sort(title: :asc_nils_last) + end + + list :comment_titles_with_5_likes, :comments, :title do + sort(title: :asc_nils_last) + filter(expr(likes >= 5)) + end + + list :comment_titles_with_popular_ratings, :comments, :title do + sort(title: :asc_nils_last) + filter(expr(not is_nil(popular_ratings.id))) + end + + list(:comment_ids, :comments, :id) + + list :comment_ids_with_default, :comments, :id do + default(["11111111-1111-1111-1111-111111111111"]) + end + + list :comment_likes_with_integer_default, :comments, :likes do + default([42]) + end + + list :linked_post_titles, :linked_posts, :title do + sort(title: :asc_nils_last) + end + + list :linked_post_titles_with_author, :linked_posts, :title do + sort(title: :asc_nils_last) + filter(expr(not is_nil(author.id))) + end + + list :linked_post_titles_with_author_join_filter, :linked_posts, :title do + sort(title: :asc_nils_last) + join_filter(:linked_posts, expr(not is_nil(author.id))) + end + + custom(:comment_titles_joined, :comments, :string) do + implementation({AshSqlite.Test.StringAgg, field: :title, delimiter: ","}) + end + + custom(:total_comment_likes_custom, :comments, :float) do + implementation({AshSqlite.Test.TotalAgg, field: :likes}) + end + + custom(:comment_titles_joined_with_popular_ratings, :comments, :string) do + filter(expr(not is_nil(popular_ratings.id))) + implementation({AshSqlite.Test.StringAgg, field: :title, delimiter: ","}) + end + + custom(:linked_post_titles_joined, :linked_posts, :string) do + implementation({AshSqlite.Test.StringAgg, field: :title, delimiter: ","}) + end + + count :count_of_comments_called_match, :comments do + filter(expr(title == "match")) + end + + count :count_of_comments_with_join_filter, :comments do + join_filter(:comments, expr(title == "match")) + end + + count :count_of_comments_with_related_filter, :comments do + filter(expr(not is_nil(post.id))) + end + + count :count_of_comments_with_related_exists_filter, :comments do + filter(expr(exists(post, not is_nil(id)))) + end + + count :count_of_comments_with_popular_ratings, :comments do + filter(expr(not is_nil(popular_ratings.id))) + end + + count :count_comment_titles_with_popular_ratings, :comments do + field(:title) + filter(expr(not is_nil(popular_ratings.id))) + end + + count :count_of_comments_with_aggregate_filter, :comments do + filter(expr(count_of_ratings > 0)) + end + + count :count_of_comments_matching_post_title, :comments do + filter(expr(title == parent(title))) + end + + count :count_of_comments_with_parent_join_filter, :comments do + join_filter(:comments, expr(title == parent(title))) + end + + exists :has_comment_called_match, :comments do + filter(expr(title == "match")) + end + + exists :has_linked_post_called_match, :linked_posts do + filter(expr(title == "match")) + end + + count :count_of_linked_posts_with_join_filter, :linked_posts do + join_filter(:linked_posts, expr(title == "match")) + end + + count :count_of_linked_posts_with_author, :linked_posts do + filter(expr(not is_nil(author.id))) + end + end + calculations do calculate(:score_after_winning, :integer, expr((score || 0) + 1)) calculate(:negative_score, :integer, expr(-score)) + calculate(:has_comments, :boolean, expr(count_of_comments > 0)) + + calculate( + :comment_likes_with_score, + :integer, + expr((sum_of_comment_likes || 0) + (score || 0)) + ) + + calculate( + :linked_post_score_with_score, + :integer, + expr((sum_of_linked_post_scores || 0) + (score || 0)) + ) + calculate(:category_label, :string, expr("(" <> category <> ")")) calculate(:score_with_score, :string, expr(score <> score)) calculate(:foo_bar_from_stuff, :string, expr(stuff[:foo][:bar])) diff --git a/test/support/string_agg.ex b/test/support/string_agg.ex new file mode 100644 index 0000000..9cc6519 --- /dev/null +++ b/test/support/string_agg.ex @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.Test.StringAgg do + @moduledoc false + + use Ash.Resource.Aggregate.CustomAggregate + use AshSqlite.CustomAggregate + + import Ecto.Query + + def dynamic(opts, binding) do + field = Keyword.fetch!(opts, :field) + delimiter = Keyword.get(opts, :delimiter, ",") + + dynamic(fragment("group_concat(?, ?)", field(as(^binding), ^field), ^delimiter)) + end +end diff --git a/test/support/total_agg.ex b/test/support/total_agg.ex new file mode 100644 index 0000000..af3133d --- /dev/null +++ b/test/support/total_agg.ex @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.Test.TotalAgg do + @moduledoc false + + use Ash.Resource.Aggregate.CustomAggregate + use AshSqlite.CustomAggregate + + import Ecto.Query + + def dynamic(opts, binding) do + field = Keyword.fetch!(opts, :field) + + dynamic(fragment("total(?)", field(as(^binding), ^field))) + end +end From d276794798ccd822f8145dcd421a346584349781 Mon Sep 17 00:00:00 2001 From: Will Townsend Date: Tue, 21 Jul 2026 17:56:06 -0700 Subject: [PATCH 05/14] test(aggregates): cover grouped first semantics Add focused coverage for the parts of `first` that differ from a simple ordered `LIMIT 1` query. Coverage includes: - excluding or including nil values; - applying a fallback to an empty filtered result; - ascending and descending ordering; - all four Ash null-ordering variants. These tests protect behavior that was missing from the original grouped query implementation. --- test/aggregate_test.exs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/aggregate_test.exs b/test/aggregate_test.exs index 2d3e2fa..f4d993b 100644 --- a/test/aggregate_test.exs +++ b/test/aggregate_test.exs @@ -54,6 +54,38 @@ defmodule AshSqlite.AggregatesTest do |> Ash.exists?() end + test "query first aggregates preserve nil, default, and sort semantics" do + create_post!(nil) + create_post!("query first a") + create_post!("query first z") + + assert %{ + ascending: "query first a", + descending: "query first z", + ascending_nils_first: nil, + ascending_nils_last: "query first a", + descending_nils_first: nil, + descending_nils_last: "query first z", + defaulted: "fallback" + } = + Ash.aggregate!(Post, [ + {:ascending, :first, field: :title, query: [sort: [title: :asc]]}, + {:descending, :first, field: :title, query: [sort: [title: :desc]]}, + {:ascending_nils_first, :first, + field: :title, include_nil?: true, query: [sort: [title: :asc_nils_first]]}, + {:ascending_nils_last, :first, + field: :title, include_nil?: true, query: [sort: [title: :asc_nils_last]]}, + {:descending_nils_first, :first, + field: :title, include_nil?: true, query: [sort: [title: :desc_nils_first]]}, + {:descending_nils_last, :first, + field: :title, include_nil?: true, query: [sort: [title: :desc_nils_last]]}, + {:defaulted, :first, + field: :title, + default: "fallback", + query: [filter: [title: "missing"], sort: [title: :asc]]} + ]) + end + test "pagination returns the count" do Ash.Seed.seed!(%Post{title: "foo"}) Ash.Seed.seed!(%Post{title: "foo"}) From 841221c047e91fa65f4efe8f62010e74c5a1e914 Mon Sep 17 00:00:00 2001 From: Will Townsend Date: Tue, 21 Jul 2026 22:10:53 -0700 Subject: [PATCH 06/14] test(aggregates): cover fieldless distinct counts Exercise fieldless distinct counts against the primary-key shapes supported by Ash resources. Coverage includes: - a valid single primary key whose Ash name is not `:id`; - a composite primary key; - a resource without a primary key; - clear errors for the unsupported key shapes. These tests protect the grouped query path from assuming that every resource has an `:id` attribute. --- test/aggregate_test.exs | 36 ++++++++++++++++++- test/support/domain.ex | 1 + .../resources/post_with_named_primary_key.ex | 30 ++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 test/support/resources/post_with_named_primary_key.ex diff --git a/test/aggregate_test.exs b/test/aggregate_test.exs index f4d993b..6decfe1 100644 --- a/test/aggregate_test.exs +++ b/test/aggregate_test.exs @@ -6,7 +6,17 @@ defmodule AshSqlite.AggregatesTest do use AshSqlite.RepoCase, async: false require Ash.Query - alias AshSqlite.Test.{Author, Comment, Post, PostLink, Profile, Rating} + + alias AshSqlite.Test.{ + Author, + Comment, + Post, + PostLink, + PostView, + PostWithNamedPrimaryKey, + Profile, + Rating + } test "a count with a filter returns the appropriate value" do Ash.Seed.seed!(%Post{title: "foo"}) @@ -85,6 +95,30 @@ defmodule AshSqlite.AggregatesTest do query: [filter: [title: "missing"], sort: [title: :asc]]} ]) end + test "fieldless unique query counts use the resource primary key" do + create_post!("named primary key a") + create_post!("named primary key b") + + assert %{unique_count: 2} = + Ash.aggregate!(PostWithNamedPrimaryKey, [ + {:unique_count, :count, uniq?: true} + ]) + end + + test "fieldless unique query counts reject composite primary keys" do + assert_raise Ash.Error.Unknown, + ~r/requires a single primary key.*composite primary key/, + fn -> + Ash.aggregate!(PostLink, [{:unique_count, :count, uniq?: true}]) + end + end + + test "fieldless unique query counts reject resources without primary keys" do + assert_raise Ash.Error.Unknown, ~r/requires a single primary key.*has no primary key/, fn -> + Ash.aggregate!(PostView, [{:unique_count, :count, uniq?: true}]) + end + end + test "pagination returns the count" do Ash.Seed.seed!(%Post{title: "foo"}) diff --git a/test/support/domain.ex b/test/support/domain.ex index 1f8abf4..57e2731 100644 --- a/test/support/domain.ex +++ b/test/support/domain.ex @@ -8,6 +8,7 @@ defmodule AshSqlite.Test.Domain do resources do resource(AshSqlite.Test.Post) + resource(AshSqlite.Test.PostWithNamedPrimaryKey) resource(AshSqlite.Test.Comment) resource(AshSqlite.Test.IntegerPost) resource(AshSqlite.Test.Rating) diff --git a/test/support/resources/post_with_named_primary_key.ex b/test/support/resources/post_with_named_primary_key.ex new file mode 100644 index 0000000..3d5412a --- /dev/null +++ b/test/support/resources/post_with_named_primary_key.ex @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.Test.PostWithNamedPrimaryKey do + @moduledoc false + use Ash.Resource, + domain: AshSqlite.Test.Domain, + data_layer: AshSqlite.DataLayer + + sqlite do + table("posts") + repo(AshSqlite.TestRepo) + migrate?(false) + end + + actions do + defaults([:read]) + end + + attributes do + attribute :post_key, :uuid do + source(:id) + primary_key?(true) + allow_nil?(false) + end + + attribute(:title, :string, public?: true) + end +end From 58cfcbe04ae9e1968c3487c77562198b44b33ef5 Mon Sep 17 00:00:00 2001 From: Will Townsend Date: Tue, 21 Jul 2026 17:57:44 -0700 Subject: [PATCH 07/14] test(aggregates): verify grouped type constraints Verify that grouped list and first expressions retain the aggregate item's type constraints. Implementation notes: - build the aggregate data-layer query directly; - inspect the generated Ecto type for the list value; - assert that `trim?` and `allow_empty?` survive query construction. This protects a constraint loss inherited from the earlier SQLite implementation rather than a defect introduced by the extraction. --- test/aggregate_test.exs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/aggregate_test.exs b/test/aggregate_test.exs index 6decfe1..036fd22 100644 --- a/test/aggregate_test.exs +++ b/test/aggregate_test.exs @@ -120,6 +120,22 @@ defmodule AshSqlite.AggregatesTest do end + test "grouped aggregate Ecto types preserve item constraints" do + {:ok, query} = + Post + |> Ash.Query.load(:comment_titles) + |> Ash.Query.data_layer_query() + + assert {:merge, _, [_, {:%{}, _, fields}]} = query.select.expr + + assert {:type, _, [_value, {:array, parameterized_type}]} = + Keyword.fetch!(fields, :comment_titles) + + assert {:parameterized, {Ash.Type.String.EctoType, constraints}} = parameterized_type + assert constraints[:trim?] + refute constraints[:allow_empty?] + end + test "pagination returns the count" do Ash.Seed.seed!(%Post{title: "foo"}) Ash.Seed.seed!(%Post{title: "foo"}) From 853b2f8667c0bb3d11ef865af363c51a989b1b0d Mon Sep 17 00:00:00 2001 From: Will Townsend Date: Tue, 21 Jul 2026 22:11:00 -0700 Subject: [PATCH 08/14] test(aggregates): cover offset-only query inputs Verify that an ordered root query with only an offset is bounded before aggregation. Implementation notes: - create three ordered records; - offset the first record without specifying a limit; - assert that the aggregate sees the remaining two records; - exercise SQLite's required `LIMIT -1` representation for a standalone offset. The missing offset condition came from existing AshSQL wrapping logic and was copied into the grouped path. --- test/aggregate_test.exs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/aggregate_test.exs b/test/aggregate_test.exs index 036fd22..5f5cc20 100644 --- a/test/aggregate_test.exs +++ b/test/aggregate_test.exs @@ -95,6 +95,7 @@ defmodule AshSqlite.AggregatesTest do query: [filter: [title: "missing"], sort: [title: :asc]]} ]) end + test "fieldless unique query counts use the resource primary key" do create_post!("named primary key a") create_post!("named primary key b") @@ -119,6 +120,17 @@ defmodule AshSqlite.AggregatesTest do end end + test "offset-only query aggregates count the offset result set" do + create_post!("offset aggregate a") + create_post!("offset aggregate b") + create_post!("offset aggregate c") + + assert 2 == + Post + |> Ash.Query.sort(:title) + |> Ash.Query.offset(1) + |> Ash.count!() + end test "grouped aggregate Ecto types preserve item constraints" do {:ok, query} = From 2fded0c79b20caf8f701fbb2f7be9ebdfc6cbafa Mon Sep 17 00:00:00 2001 From: Will Townsend Date: Tue, 21 Jul 2026 22:11:20 -0700 Subject: [PATCH 09/14] test(aggregates): cover rich and limited root queries Add root-query regressions for bounded inputs and non-attribute aggregate fields. Coverage includes: - `first` and `exists` over a zero-limit query; - a sorted and limited query whose outer aggregate has its own sort; - an offset query with no remaining rows; - calculation fields and aggregate-on-aggregate fields. These tests protect grouped query behavior introduced by the aggregate extraction. --- test/aggregate_test.exs | 43 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/test/aggregate_test.exs b/test/aggregate_test.exs index 5f5cc20..0da64de 100644 --- a/test/aggregate_test.exs +++ b/test/aggregate_test.exs @@ -132,6 +132,49 @@ defmodule AshSqlite.AggregatesTest do |> Ash.count!() end + test "root first and exists aggregates use the limited query input" do + create_post!("limited input a") + create_post!("limited input b") + create_post!("limited input c") + + empty_query = Ash.Query.limit(Post, 0) + + assert %{first_title: nil, exists_any: false} = + Ash.aggregate!(empty_query, [ + {:first_title, :first, field: :title, query: [sort: [title: :asc]]}, + {:exists_any, :exists} + ]) + + assert %{first_title: "limited input c"} = + Post + |> Ash.Query.sort(title: :desc) + |> Ash.Query.limit(1) + |> Ash.aggregate!([ + {:first_title, :first, field: :title, query: [sort: [title: :asc]]} + ]) + + refute Post + |> Ash.Query.sort(title: :asc) + |> Ash.Query.offset(3) + |> Ash.Query.limit(1) + |> Ash.exists?() + end + + test "query aggregates resolve calculation and aggregate fields" do + first = create_post!("rich query field a", %{score: 1}) + second = create_post!("rich query field b", %{score: 2}) + + create_comment!(first, "first", 1) + create_comment!(second, "second", 1) + create_comment!(second, "third", 1) + + assert %{winning_score_total: 5, comment_count_total: 3} = + Ash.aggregate!(Post, [ + {:winning_score_total, :sum, field: :score_after_winning}, + {:comment_count_total, :sum, field: :count_of_comments} + ]) + end + test "grouped aggregate Ecto types preserve item constraints" do {:ok, query} = Post From 07978c2eefcea55ae80e9cf81ce887761f064469 Mon Sep 17 00:00:00 2001 From: Will Townsend Date: Tue, 21 Jul 2026 22:11:32 -0700 Subject: [PATCH 10/14] test(aggregates): cover rich and bounded relationship loads Add loaded-aggregate regressions for relationship bounds and rich related fields. Coverage includes: - per-parent relationship limits and offsets; - count and list results over each bounded relationship; - calculation fields on related records; - aggregate fields on related records; - sum, maximum, first, and list results. These tests protect grouped loading behavior introduced by the aggregate extraction. --- test/aggregate_test.exs | 48 +++++++++++++++++++++++++++++++ test/support/resources/comment.ex | 4 +++ test/support/resources/post.ex | 36 +++++++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/test/aggregate_test.exs b/test/aggregate_test.exs index 0da64de..785076b 100644 --- a/test/aggregate_test.exs +++ b/test/aggregate_test.exs @@ -282,6 +282,54 @@ defmodule AshSqlite.AggregatesTest do |> Ash.read!() end + test "relationship limits and offsets constrain loaded aggregates" do + post = create_post!("limited relationship") + + for likes <- 1..5 do + create_comment!(post, "comment #{likes}", likes) + end + + assert %{ + count_of_top_comments: 2, + top_comment_likes: [5, 4], + count_of_middle_comments: 2, + middle_comment_likes: [4, 3], + count_of_comments_after_top: 3, + comment_likes_after_top: [3, 2, 1] + } = + Ash.load!(post, [ + :count_of_top_comments, + :top_comment_likes, + :count_of_middle_comments, + :middle_comment_likes, + :count_of_comments_after_top, + :comment_likes_after_top + ]) + end + + test "loaded aggregates resolve calculation and aggregate fields" do + post = create_post!("rich loaded fields") + first = create_comment!(post, "first", 2) + second = create_comment!(post, "second", 4) + + create_comment_rating!(first, 1) + create_comment_rating!(first, 2) + create_comment_rating!(second, 3) + + assert %{ + sum_of_comment_double_likes: 12, + sum_of_comment_rating_counts: 3, + highest_comment_double_likes: 8, + comment_double_likes: [4, 8] + } = + Ash.load!(post, [ + :sum_of_comment_double_likes, + :sum_of_comment_rating_counts, + :highest_comment_double_likes, + :comment_double_likes + ]) + end + test "fieldless count aggregates use SQL count star" do {:ok, query} = Post diff --git a/test/support/resources/comment.ex b/test/support/resources/comment.ex index 25374ae..8148c30 100644 --- a/test/support/resources/comment.ex +++ b/test/support/resources/comment.ex @@ -74,4 +74,8 @@ defmodule AshSqlite.Test.Comment do aggregates do count(:count_of_ratings, :ratings) end + + calculations do + calculate(:double_likes, :integer, expr(likes * 2)) + end end diff --git a/test/support/resources/post.ex b/test/support/resources/post.ex index ea01a87..f1876b4 100644 --- a/test/support/resources/post.ex +++ b/test/support/resources/post.ex @@ -127,6 +127,25 @@ defmodule AshSqlite.Test.Post do has_many(:comments, AshSqlite.Test.Comment, destination_attribute: :post_id, public?: true) + has_many :top_comments, AshSqlite.Test.Comment do + destination_attribute(:post_id) + sort(likes: :desc) + limit(2) + end + + has_many :middle_comments, AshSqlite.Test.Comment do + destination_attribute(:post_id) + sort(likes: :desc) + offset(1) + limit(2) + end + + has_many :comments_after_top, AshSqlite.Test.Comment do + destination_attribute(:post_id) + sort(likes: :desc) + offset(2) + end + has_many :comments_matching_post_title, AshSqlite.Test.Comment do public?(true) filter(expr(title == parent_expr(title))) @@ -178,12 +197,17 @@ defmodule AshSqlite.Test.Post do aggregates do count(:count_of_comments, :comments) + count(:count_of_top_comments, :top_comments) + count(:count_of_middle_comments, :middle_comments) + count(:count_of_comments_after_top, :comments_after_top) count(:count_of_popular_comments, :popular_comments) count(:count_of_linked_posts, :linked_posts) count(:count_of_comments_through_linked_posts, [:linked_posts, :comments]) count(:count_of_liked_comments, :comments, read_action: :liked) count(:count_of_comment_ratings, [:comments, :ratings]) sum(:sum_of_comment_likes, :comments, :likes) + sum(:sum_of_comment_double_likes, :comments, :double_likes) + sum(:sum_of_comment_rating_counts, :comments, :count_of_ratings) sum(:sum_of_comment_likes_called_match, :comments, :likes, filter: expr(title == "match")) sum(:sum_of_comment_likes_with_popular_ratings, :comments, :likes) do @@ -211,6 +235,10 @@ defmodule AshSqlite.Test.Post do sort(title: :asc_nils_last) end + first :highest_comment_double_likes, :comments, :double_likes do + sort(double_likes: :desc) + end + first :first_comment_nils_first, :comments, :title do sort(title: :asc_nils_first) end @@ -257,6 +285,14 @@ defmodule AshSqlite.Test.Post do sort(title: :asc_nils_last) end + list(:top_comment_likes, :top_comments, :likes) + list(:middle_comment_likes, :middle_comments, :likes) + list(:comment_likes_after_top, :comments_after_top, :likes) + + list :comment_double_likes, :comments, :double_likes do + sort(double_likes: :asc) + end + list :comment_titles_with_empty_default, :comments, :title do default([]) sort(title: :asc_nils_last) From 44ee132d320aea79c5fef0bef9891c9f8a90d509 Mon Sep 17 00:00:00 2001 From: Will Townsend Date: Tue, 21 Jul 2026 22:11:42 -0700 Subject: [PATCH 11/14] docs(aggregates): document SQLite aggregate support Add a complete guide to the grouped aggregate behavior exposed by AshSQLite. Documentation includes: - supported root and loaded aggregate kinds; - calculations, filters, sorting, pagination, and relationship paths; - custom aggregates, limits, offsets, and unsupported shapes; - SQLite requirements for windows, `FILTER`, JSON, and null ordering; - navigation, tutorial, API grouping, and changelog updates. The guide reflects the behavior exercised by the preceding acceptance commits. --- CHANGELOG.md | 7 + README.md | 1 + .../about-ash-sqlite/what-is-ash-sqlite.md | 2 +- documentation/topics/resources/aggregates.md | 245 ++++++++++++++++++ .../getting-started-with-ash-sqlite.md | 68 ++++- mix.exs | 4 + 6 files changed, 322 insertions(+), 5 deletions(-) create mode 100644 documentation/topics/resources/aggregates.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b2b851..21ac4ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline +## Unreleased + +### Improvements: + +* support SQLite aggregates for loading, filtering, sorting, calculations, and root query aggregates +* add grouped aggregate regression coverage for list defaults and root query aggregate kinds + ## [v0.2.19](https://github.com/ash-project/ash_sqlite/compare/v0.2.18...v0.2.19) (2026-09-05) diff --git a/README.md b/README.md index 1f3d18f..d8d68a3 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Welcome! `AshSqlite` is the SQLite data layer for [Ash Framework](https://hexdoc ### Resources +- [Aggregates](documentation/topics/resources/aggregates.md) - [References](documentation/topics/resources/references.md) - [Polymorphic Resources](documentation/topics/resources/polymorphic-resources.md) diff --git a/documentation/topics/about-ash-sqlite/what-is-ash-sqlite.md b/documentation/topics/about-ash-sqlite/what-is-ash-sqlite.md index 08abe09..6985940 100644 --- a/documentation/topics/about-ash-sqlite/what-is-ash-sqlite.md +++ b/documentation/topics/about-ash-sqlite/what-is-ash-sqlite.md @@ -6,7 +6,7 @@ SPDX-License-Identifier: MIT # What is AshSqlite? -AshSqlite is the SQLite `Ash.DataLayer` for [Ash Framework](https://hexdocs.pm/ash). This doesn't have all of the features of [AshPostgres](https://hexdocs.pm/ash_postgres), but it does support most of the features of Ash data layers. The main feature missing is Aggregate support. +AshSqlite is the SQLite `Ash.DataLayer` for [Ash Framework](https://hexdocs.pm/ash). This doesn't have all of the features of [AshPostgres](https://hexdocs.pm/ash_postgres), but it does support most of the features of Ash data layers. AshSqlite supports related aggregates, filters, sorts, and expression calculations for common aggregate workflows. See the [AshSqlite aggregates guide](../resources/aggregates.md) for supported aggregate cases and SQLite-specific limitations. Use this to persist records in a SQLite table. For example, the resource below would be persisted in a table called `tweets`: diff --git a/documentation/topics/resources/aggregates.md b/documentation/topics/resources/aggregates.md new file mode 100644 index 0000000..3f68d71 --- /dev/null +++ b/documentation/topics/resources/aggregates.md @@ -0,0 +1,245 @@ + + +# Aggregates + +AshSqlite supports resource aggregates that can be loaded, filtered, sorted, and used in expression calculations. For general Ash aggregate usage, see the [Ash aggregates guide](https://hexdocs.pm/ash/aggregates.html). + +## Supported Aggregates + +AshSqlite supports related `count`, `sum`, `avg`, `min`, `max`, `exists`, `first`, `list`, and `custom` aggregates over normal relationship paths. + +```elixir +aggregates do + count :total_tickets, :tickets + exists :has_open_tickets, :tickets do + filter expr(status == :open) + end + + first :first_ticket_subject, :tickets, :subject do + sort subject: :asc_nils_last + end + + list :ticket_subjects, :tickets, :subject do + sort subject: :asc_nils_last + end +end +``` + +Aggregates are translated to SQL and can be used in queries. + +```elixir +require Ash.Query + +Helpdesk.Support.Representative +|> Ash.Query.filter(total_tickets > 2) +|> Ash.Query.sort(total_tickets: :desc) +|> Ash.Query.load([:total_tickets, :first_ticket_subject]) +|> Ash.read!() +``` + +Aggregates can also be loaded on records that have already been read. + +```elixir +representatives = Helpdesk.Support.read!(Helpdesk.Support.Representative) + +Ash.load!(representatives, [:total_tickets, :ticket_subjects]) +``` + +## Query Aggregates + +AshSqlite supports root query aggregates for `count`, `sum`, `avg`, `min`, `max`, +`first`, and `exists`. + +```elixir +Helpdesk.Support.Representative +|> Ash.Query.filter(active == true) +|> Ash.aggregate!(count: :count) +``` + +Relationship query aggregates are not supported by the grouped aggregate query +path. Define a resource aggregate and load, filter, or sort on that aggregate +instead. + +Root query aggregate limits and offsets are applied before aggregation. A +fieldless `count` with `uniq?: true` counts the resource's primary key and +requires the resource to have exactly one primary-key attribute. + +## Calculations + +Expression calculations can reference aggregates and be pushed down to SQLite. + +```elixir +aggregates do + count :total_tickets, :tickets + + count :open_tickets, :tickets do + filter expr(status == :open) + end +end + +calculations do + calculate :percent_open, :float, expr(open_tickets / total_tickets) +end +``` + +Calculations that reference aggregates can be loaded, filtered, and sorted in the same way. + +```elixir +require Ash.Query + +Helpdesk.Support.Representative +|> Ash.Query.filter(percent_open > 0.25) +|> Ash.Query.sort(:percent_open) +|> Ash.Query.load(:percent_open) +|> Ash.read!() +``` + +## Relationship Paths + +Aggregates are supported over normal relationship paths, including multi-hop paths. + +```elixir +aggregates do + count :comment_count, [:posts, :comments] + sum :paid_total, [:orders, :payments], :amount +end +``` + +One-hop many-to-many relationship aggregates are supported. Scalar aggregates are also supported when a multi-hop path ends in a many-to-many relationship. + +```elixir +aggregates do + count :linked_post_count, :linked_posts + count :linked_post_count_through_posts, [:posts, :linked_posts] + + first :first_linked_post_title, :linked_posts, :title do + sort title: :asc_nils_last + end +end +``` + +Parent-independent unrelated aggregates are supported when the aggregate query does not need values from the parent row. + +```elixir +aggregates do + count :published_post_count, Post do + filter expr(published == true) + end +end +``` + +## Aggregate Filters + +Aggregate filters and aggregate `join_filter`s are supported for normal paths and one-hop many-to-many paths when they do not depend on parent row values. + +```elixir +aggregates do + count :open_ticket_count, :tickets do + filter expr(status == :open) + end + + count :matching_ticket_count, :tickets do + join_filter :tickets, expr(priority == :high) + end +end +``` + +For many-to-many aggregates, a `join_filter` on the many-to-many relationship applies to the destination resource side of the aggregate. Put through-resource filtering on the relationship's configured join relationship/filter. + +For filters that need to test a to-many relationship without multiplying the aggregate rows, prefer `exists/2`. + +```elixir +aggregates do + sum :liked_comment_total, :comments, :likes do + filter expr(exists(ratings, score > 5)) + end +end +``` + +Multi-hop aggregates use each relationship's configured read action. If an intermediate hop needs scoped rows, define the read action on that relationship rather than trying to override it per aggregate. + +## SQLite Requirements + +Aggregate filters and `first`/`list` aggregate ordering require SQLite 3.30.0 or later. Window functions were added in SQLite 3.25.0, but AshSqlite's generated SQL also uses aggregate `FILTER` clauses and explicit `NULLS FIRST`/`NULLS LAST` ordering, which require SQLite 3.30.0 or later. `list` aggregates also require JSON functions. + +- window functions +- aggregate `FILTER` +- JSON aggregation +- explicit null ordering + +JSON functions are built into SQLite by default as of SQLite 3.38.0. Older SQLite builds need the JSON1 extension enabled. Check the SQLite library used by your application, which may not be the same binary as the `sqlite3` command: + +```elixir +MyApp.Repo.query!("select sqlite_version()") +MyApp.Repo.query!("select json_group_array(1)") +``` + +`list` aggregates return lists through SQLite JSON aggregation. `custom` aggregates require a SQLite-compatible aggregate expression or function. + +## Custom Aggregates + +Custom aggregates should use both `Ash.Resource.Aggregate.CustomAggregate` and `AshSqlite.CustomAggregate`. + +```elixir +defmodule MyApp.StringAgg do + use Ash.Resource.Aggregate.CustomAggregate + use AshSqlite.CustomAggregate + + require Ecto.Query + + def dynamic(opts, binding) do + Ecto.Query.dynamic( + [], + fragment("group_concat(?, ?)", field(as(^binding), ^opts[:field]), ^opts[:delimiter]) + ) + end +end +``` + +Then use that implementation from a resource aggregate. + +```elixir +aggregates do + custom :ticket_subjects_joined, :tickets, :string do + implementation {MyApp.StringAgg, field: :subject, delimiter: ", "} + end +end +``` + +`AshSqlite.CustomAggregate` only defines the `dynamic/2` contract. Custom aggregates must return SQLite-compatible Ecto dynamic expressions. This behaviour does not install SQLite extensions or register user-defined functions. If your custom aggregate uses a function that is not built into SQLite, register it with the SQLite connection yourself and make sure it is available in every environment. + +## Performance + +AshSqlite builds aggregate queries as grouped subqueries or windowed subqueries and joins those results back to the parent query. Add indexes for the relationship keys used by those subqueries. + +Useful indexes usually include: + +- child foreign keys, like `tickets.representative_id` +- many-to-many join resource key pairs +- fields used by aggregate filters +- fields used by `first` and `list` aggregate sorts + +## Unsupported Cases + +Full aggregate parity with [AshPostgres](https://hexdocs.pm/ash_postgres) is not available. Unsupported cases include: + +- inline query-level `list` and `custom` aggregate expressions +- relationship query aggregates +- unrelated aggregates that reference the parent row +- manual relationships +- `no_attributes?` relationships +- multi-hop paths that include many-to-many relationships before the final hop +- non-scalar aggregates over multi-hop paths that include many-to-many relationships +- parent-dependent relationship filters +- parent-dependent aggregate filters +- parent-dependent `join_filter`s +- aggregate filters that reference other aggregates +- expression sorts on `first` and `list` aggregates +- `uniq` list aggregates sorted by fields other than the listed field +- fanout-prone `sum`, `avg`, `list`, `custom`, or field-based `count` aggregate filters over to-many relationship references + +A fanout-prone aggregate filter is one where filtering joins another to-many relationship and can duplicate the rows being aggregated. For example, a `sum` of comment likes filtered by `popular_ratings.id` could count the same comment once per matching rating. AshSqlite rejects these shapes instead of returning an over-counted result. Use `exists/2` when you only need to test that related rows exist. diff --git a/documentation/tutorials/getting-started-with-ash-sqlite.md b/documentation/tutorials/getting-started-with-ash-sqlite.md index 6582890..abbbf1a 100644 --- a/documentation/tutorials/getting-started-with-ash-sqlite.md +++ b/documentation/tutorials/getting-started-with-ash-sqlite.md @@ -326,11 +326,71 @@ Helpdesk.Support.Ticket ### Aggregates -As stated in [what-is-ash-sqlite](https://hexdocs.pm/ash_sqlite/getting-started-with-ash-sqlite.html#steps), -**The main feature missing is Aggregate support.**. +Aggregates include grouped data about relationships. You can read more about them in the [Ash aggregates guide](https://hexdocs.pm/ash/aggregates.html) and the [AshSqlite aggregates guide](../topics/resources/aggregates.md). -In order to use these consider using [ash_postgres](https://github.com/ash-project/ash_postgres) or -provide a patch. +Let's add aggregates to the representative resource so we can query how many tickets are assigned to a representative, how many are open, and the first ticket subject. + +```elixir +# in lib/helpdesk/support/resources/representative.ex + + aggregates do + count :total_tickets, :tickets + + count :open_tickets, :tickets do + filter expr(status == :open) + end + + exists :has_closed_tickets, :tickets do + filter expr(status == :closed) + end + + first :first_ticket_subject, :tickets, :subject do + sort subject: :asc_nils_last + end + end +``` + +Aggregates are translated to SQL and can be used in filters and sorts. + +```elixir +require Ash.Query + +Helpdesk.Support.Representative +|> Ash.Query.filter(open_tickets > 0) +|> Ash.Query.sort(total_tickets: :desc) +|> Ash.Query.load([:total_tickets, :open_tickets, :first_ticket_subject]) +|> Ash.read!() +``` + +You can also load individual aggregates after records have already been read. + +```elixir +representatives = Helpdesk.Support.read!(Helpdesk.Support.Representative) + +Ash.load!(representatives, [:open_tickets, :has_closed_tickets]) +``` + +Calculations can refer to aggregates, and those calculations can also be filtered, sorted, and loaded. + +```elixir +# in lib/helpdesk/support/resources/representative.ex + + calculations do + calculate :percent_open, :float, expr(open_tickets / total_tickets) + end +``` + +```elixir +require Ash.Query + +Helpdesk.Support.Representative +|> Ash.Query.filter(percent_open > 0.25) +|> Ash.Query.sort(:percent_open) +|> Ash.Query.load(:percent_open) +|> Ash.read!() +``` + +AshSqlite supports related `count`, `sum`, `avg`, `min`, `max`, `exists`, `first`, `list`, and `custom` aggregates over normal relationship paths, one-hop many-to-many relationship aggregates, scalar aggregates over multi-hop paths that end in a many-to-many relationship, and parent-independent unrelated aggregates. `first` and `list` aggregates require SQLite 3.30.0 or later with JSON functions enabled. ### Rich Configuration Options diff --git a/mix.exs b/mix.exs index 1362cae..c27102d 100644 --- a/mix.exs +++ b/mix.exs @@ -88,6 +88,7 @@ defmodule AshSqlite.MixProject do "documentation/tutorials/getting-started-with-ash-sqlite.md", "documentation/topics/about-ash-sqlite/what-is-ash-sqlite.md", "documentation/topics/about-ash-sqlite/transactions.md", + "documentation/topics/resources/aggregates.md", "documentation/topics/resources/references.md", "documentation/topics/resources/polymorphic-resources.md", "documentation/topics/development/migrations-and-tasks.md", @@ -131,6 +132,9 @@ defmodule AshSqlite.MixProject do Types: [ AshSqlite.Type ], + "Custom Aggregates": [ + AshSqlite.CustomAggregate + ], Expressions: [ AshSqlite.Functions.Fragment, AshSqlite.Functions.Like From 479187489010c0d8d8bfda184c74ddcd35850fc6 Mon Sep 17 00:00:00 2001 From: Will Townsend Date: Fri, 18 Sep 2026 21:23:14 -0700 Subject: [PATCH 12/14] fix(aggregates): preserve public names and cover binding regressions Remap internal aggregate aliases when returning normal query results. Add SQL regressions for nested bindings, separate relationship paths, string names, and reuse with identical or different definitions. Verify inline aggregate calculations now return their values with shared name normalization. --- documentation/topics/resources/aggregates.md | 8 +- lib/data_layer.ex | 7 +- test/aggregate_binding_test.exs | 148 +++++++++++++++++++ test/calculation_test.exs | 5 +- 4 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 test/aggregate_binding_test.exs diff --git a/documentation/topics/resources/aggregates.md b/documentation/topics/resources/aggregates.md index 3f68d71..d28a844 100644 --- a/documentation/topics/resources/aggregates.md +++ b/documentation/topics/resources/aggregates.md @@ -49,6 +49,12 @@ representatives = Helpdesk.Support.read!(Helpdesk.Support.Representative) Ash.load!(representatives, [:total_tickets, :ticket_subjects]) ``` +Loaded query aggregates may use string names. Their values retain those names +in the record's `aggregates` map. Aggregate references through relationships, +such as `comments.count_of_ratings`, are evaluated against the related record. +Expression calculations can also use inline related aggregates, including +`list(posts, field: :title)`. + ## Query Aggregates AshSqlite supports root query aggregates for `count`, `sum`, `avg`, `min`, `max`, @@ -227,7 +233,7 @@ Useful indexes usually include: Full aggregate parity with [AshPostgres](https://hexdocs.pm/ash_postgres) is not available. Unsupported cases include: -- inline query-level `list` and `custom` aggregate expressions +- root `list` and `custom` query aggregates through `Ash.aggregate/2` - relationship query aggregates - unrelated aggregates that reference the parent row - manual relationships diff --git a/lib/data_layer.ex b/lib/data_layer.ex index 86c8a39..6254871 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -641,10 +641,9 @@ defmodule AshSqlite.DataLayer do AshSql.repo_opts(repo, AshSqlite.SqlImplementation, nil, nil, resource) {:ok, - repo.all( - query, - opts - )} + query + |> repo.all(opts) + |> AshSql.Query.remap_mapped_fields(query)} end end rescue diff --git a/test/aggregate_binding_test.exs b/test/aggregate_binding_test.exs new file mode 100644 index 0000000..0ec372b --- /dev/null +++ b/test/aggregate_binding_test.exs @@ -0,0 +1,148 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.AggregateBindingTest do + use AshSqlite.RepoCase, async: false + + require Ash.Query + + alias AshSqlite.Test.{Comment, Post, Rating} + + test "a related aggregate filter correlates with the joined resource" do + rated_post = post!("rated") + unrated_post = post!("unrated") + rated_comment = comment!(rated_post, 1) + comment!(unrated_post, 1) + refute rated_comment.id == rated_post.id + + rate!(rated_comment) + + assert [result] = + Post + |> Ash.Query.filter(comments.count_of_ratings > 0) + |> Ash.read!() + + assert result.id == rated_post.id + end + + test "the same aggregate through two relationship paths is not reused across paths" do + ordinary_rated = post!("ordinary rated") + ordinary_rated |> comment!(1) |> rate!() + comment!(ordinary_rated, 20) + + popular_rated = post!("popular rated") + popular_rated |> comment!(20) |> rate!() + + assert [result] = + Post + |> Ash.Query.filter( + comments.count_of_ratings > 0 and popular_comments.count_of_ratings == 0 + ) + |> Ash.read!() + + assert result.id == ordinary_rated.id + end + + test "a string-named loaded aggregate retains its public name" do + post = post!("string name") + comment!(post, 1) + comment!(post, 2) + + assert [result] = + Post + |> Ash.Query.aggregate("comment_count", :count, :comments) + |> Ash.read!() + + assert result.aggregates["comment_count"] == 2 + end + + test "same-named aggregates with different filters keep distinct results" do + post = post!("filters") + comment!(post, 1) + comment!(post, 10) + comment!(post, 20) + + low = aggregate!(:count, :low_count, filter: [likes: [less_than: 5]]) + high = aggregate!(:count, :high_count, filter: [likes: [greater_than: 5]]) + + assert %{low_count: 1, high_count: 2} = attach_and_run([low, high]) + end + + test "same-named first aggregates with different sorts keep distinct results" do + post = post!("sorts") + comment!(post, 1) + comment!(post, 10) + + first = aggregate!(:first, :lowest, sort: [likes: :asc]) + last = aggregate!(:first, :highest, sort: [likes: :desc]) + + assert %{lowest: 1, highest: 10} = attach_and_run([first, last]) + end + + test "an identical aggregate reuses its join when selected a second time" do + post = post!("reused") + comment!(post, 1) + aggregate = aggregate!(:count, :first_count, []) + + query = attach([aggregate]) + {:ok, selected_again} = add_aggregate(query, %{aggregate | load: :second_count}) + + assert length(selected_again.joins) == length(query.joins) + assert %{first_count: 1, second_count: 1} = TestRepo.one!(selected_again) + end + + defp aggregate!(kind, load, query_opts) do + {:ok, aggregate} = + Ash.Query.Aggregate.new(Post, :same_name, kind, + path: [:comments], + field: if(kind == :first, do: :likes), + query: Ash.Query.build(Comment, query_opts) + ) + + %{aggregate | load: load} + end + + defp attach_and_run(aggregates) do + aggregates |> attach() |> TestRepo.one!() + end + + defp attach(aggregates) do + {:ok, query} = Ash.Query.data_layer_query(Ash.Query.new(Post)) + query = query |> Ecto.Query.exclude(:select) |> select(%{}) + + Enum.reduce(aggregates, query, fn aggregate, query -> + {:ok, query} = add_aggregate(query, aggregate) + + query + end) + end + + defp add_aggregate(query, aggregate) do + AshSql.Aggregate.add_aggregates( + query, + [aggregate], + Post, + true, + query.__ash_bindings__.root_binding + ) + end + + defp rate!(comment) do + Rating + |> Ash.Changeset.for_create(:create, %{score: 5, resource_id: comment.id}) + |> Ash.Changeset.set_context(%{data_layer: %{table: "comment_ratings"}}) + |> Ash.create!() + end + + defp post!(title) do + Post |> Ash.Changeset.for_create(:create, %{title: title}) |> Ash.create!() + end + + defp comment!(post, likes) do + Comment + |> Ash.Changeset.for_create(:create, %{title: "comment", likes: likes}) + |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) + |> Ash.create!() + end +end diff --git a/test/calculation_test.exs b/test/calculation_test.exs index 2d10ec7..1e8db21 100644 --- a/test/calculation_test.exs +++ b/test/calculation_test.exs @@ -399,11 +399,10 @@ defmodule AshSqlite.CalculationTest do |> Enum.map(fn title -> Post |> Ash.Changeset.for_create(:create, %{title: title}) + |> Ash.Changeset.manage_relationship(:author, author, type: :append_and_remove) |> Ash.create!() end) - assert_raise Ash.Error.Unknown, ~r/only supports loading related/, fn -> - Ash.load!(author, :post_titles) - end + assert Enum.sort(Ash.load!(author, :post_titles).post_titles) == Enum.sort(post_titles) end end From 97d5601439cca6c518b8dac658377c85311e2080 Mon Sep 17 00:00:00 2001 From: Will Townsend Date: Fri, 18 Sep 2026 22:46:04 -0700 Subject: [PATCH 13/14] test(aggregates): cover preparation, tenant scope and bounded filters Add 24 public-API regression tests and controls for the shared AshSQL grouped aggregate fixes. Intermediate and join inputs could skip read preparations, lose actor/tenant or shared context, discard required action arguments, and count rows outside attribute-tenant scope. Cover configured and prepared query actions, arguments and context, plus multitenancy bypass across the relationship path without changing siblings. Verify read-action scope runs before relationship limits and aggregate filters afterward: first/list must not pull matching rows from outside a bounded relationship. Add constrained scalar-type regressions for loaded values and defaults, which previously returned raw stored integers instead of custom typed values. Document relationship preparation and filter ordering, and record the fixes in the changelog. Validation against the paired local AshSQL fix: 24 focused tests and all 274 SQLite tests pass. Failures were reproduced before their fixes, and an extra-high independent subagent approved the final implementation. Formatting, compilation, Credo, docs, dependency checks and corrected local REUSE pass; existing test-helper Dialyzer findings remain. --- CHANGELOG.md | 5 + documentation/topics/resources/aggregates.md | 5 + test/aggregate_context_test.exs | 284 ++++++++++++++++ test/aggregate_relationship_test.exs | 325 +++++++++++++++++++ test/aggregate_scalar_type_test.exs | 71 ++++ test/aggregate_scope_test.exs | 139 ++++++++ 6 files changed, 829 insertions(+) create mode 100644 test/aggregate_context_test.exs create mode 100644 test/aggregate_relationship_test.exs create mode 100644 test/aggregate_scalar_type_test.exs create mode 100644 test/aggregate_scope_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 21ac4ef..2f1b6af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline * support SQLite aggregates for loading, filtering, sorting, calculations, and root query aggregates * add grouped aggregate regression coverage for list defaults and root query aggregate kinds +### Bug Fixes: + +* preserve aggregate relationship preparations, configured arguments, and attribute tenant scope +* respect relationship limits when filtering first and list aggregates + ## [v0.2.19](https://github.com/ash-project/ash_sqlite/compare/v0.2.18...v0.2.19) (2026-09-05) diff --git a/documentation/topics/resources/aggregates.md b/documentation/topics/resources/aggregates.md index d28a844..af6f6bd 100644 --- a/documentation/topics/resources/aggregates.md +++ b/documentation/topics/resources/aggregates.md @@ -168,6 +168,11 @@ end Multi-hop aggregates use each relationship's configured read action. If an intermediate hop needs scoped rows, define the read action on that relationship rather than trying to override it per aggregate. +Intermediate relationships and many-to-many join resources retain their read +action arguments, shared context, and attribute-based tenant scope. Read action +filters apply before relationship limits and offsets. Aggregate filters apply +to the resulting bounded relationship, including for `first` and `list`. + ## SQLite Requirements Aggregate filters and `first`/`list` aggregate ordering require SQLite 3.30.0 or later. Window functions were added in SQLite 3.25.0, but AshSqlite's generated SQL also uses aggregate `FILTER` clauses and explicit `NULLS FIRST`/`NULLS LAST` ordering, which require SQLite 3.30.0 or later. `list` aggregates also require JSON functions. diff --git a/test/aggregate_context_test.exs b/test/aggregate_context_test.exs new file mode 100644 index 0000000..0e37140 --- /dev/null +++ b/test/aggregate_context_test.exs @@ -0,0 +1,284 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.AggregateContextTest do + use AshSqlite.RepoCase, async: false + + require Ash.Query + + defmodule ContextFilter do + use Ash.Resource.Preparation + + @impl true + def prepare(query, opts, context) do + value = + case opts[:from] do + :actor -> context.actor && context.actor.title + :tenant -> context.tenant + :context -> query.context[:visible_title] + end + + Ash.Query.do_filter(query, [{opts[:field], value || "missing-context"}]) + end + end + + defmodule Comment do + use Ash.Resource, + domain: AshSqlite.AggregateContextTest.Domain, + data_layer: AshSqlite.DataLayer + + sqlite do + table("comments") + repo(AshSqlite.TestRepo) + end + + attributes do + uuid_primary_key(:id) + attribute(:post_id, :uuid) + end + + actions do + defaults([:read]) + end + end + + defmodule Post do + use Ash.Resource, + domain: AshSqlite.AggregateContextTest.Domain, + data_layer: AshSqlite.DataLayer + + sqlite do + table("posts") + repo(AshSqlite.TestRepo) + end + + attributes do + uuid_primary_key(:id) + attribute(:author_id, :uuid) + attribute(:title, :string) + attribute(:category, :string) + end + + actions do + defaults([:read]) + + read :for_actor do + prepare({ContextFilter, from: :actor, field: :title}) + end + + read :for_context do + prepare({ContextFilter, from: :context, field: :title}) + end + + read :for_argument do + argument(:title, :string, allow_nil?: false) + filter(expr(title == ^arg(:title))) + end + + read :for_tenant do + prepare({ContextFilter, from: :tenant, field: :category}) + end + end + + relationships do + has_many(:comments, Comment, destination_attribute: :post_id) + + has_many(:visible_links, AshSqlite.AggregateContextTest.PostLink, + destination_attribute: :source_post_id, + read_action: :for_actor + ) + + many_to_many :linked_posts, __MODULE__ do + through(AshSqlite.AggregateContextTest.PostLink) + join_relationship(:visible_links) + source_attribute_on_join_resource(:source_post_id) + destination_attribute_on_join_resource(:destination_post_id) + end + end + end + + defmodule PostLink do + use Ash.Resource, + domain: AshSqlite.AggregateContextTest.Domain, + data_layer: AshSqlite.DataLayer + + sqlite do + table("post_links") + repo(AshSqlite.TestRepo) + end + + attributes do + attribute(:source_post_id, :uuid, primary_key?: true, allow_nil?: false) + attribute(:destination_post_id, :uuid, primary_key?: true, allow_nil?: false) + attribute(:state, :string) + end + + actions do + defaults([:read]) + + read :for_actor do + prepare({ContextFilter, from: :actor, field: :state}) + end + end + end + + defmodule Author do + use Ash.Resource, + domain: AshSqlite.AggregateContextTest.Domain, + data_layer: AshSqlite.DataLayer + + sqlite do + table("authors") + repo(AshSqlite.TestRepo) + end + + attributes do + uuid_primary_key(:id) + end + + actions do + defaults([:read]) + end + + relationships do + has_many(:source_context_posts, Post, + destination_attribute: :author_id, + read_action: :for_context + ) + + has_many(:context_posts, Post, + destination_attribute: :author_id, + read_action: :for_context, + relationship_context: %{visible_title: "alice"} + ) + + has_many(:argument_posts, Post, + destination_attribute: :author_id, + read_action: :for_argument, + read_action_arguments: %{title: "alice"} + ) + + has_many(:actor_posts, Post, destination_attribute: :author_id, read_action: :for_actor) + has_many(:tenant_posts, Post, destination_attribute: :author_id, read_action: :for_tenant) + end + + aggregates do + count(:source_context_comment_count, [:source_context_posts, :comments]) + count(:context_comment_count, [:context_posts, :comments]) + count(:argument_comment_count, [:argument_posts, :comments]) + count(:actor_post_count, :actor_posts) + count(:tenant_post_count, :tenant_posts) + count(:actor_comment_count, [:actor_posts, :comments]) + count(:tenant_comment_count, [:tenant_posts, :comments]) + end + end + + defmodule Domain do + use Ash.Domain, validate_config_inclusion?: false + + resources do + resource(Author) + resource(Post) + resource(Comment) + resource(PostLink) + end + end + + setup do + author = Ash.Seed.seed!(%AshSqlite.Test.Author{first_name: "context", last_name: "test"}) + post!(author, "alice", "acme", 1) + post!(author, "bob", "other", 2) + %{author_id: author.id} + end + + test "single-hop preparation receives the actor", %{author_id: id} do + assert read!(id, :actor_post_count, actor: %{title: "alice"}).actor_post_count == 1 + assert read!(id, :actor_post_count, actor: %{title: "bob"}).actor_post_count == 1 + end + + test "single-hop preparation receives the tenant", %{author_id: id} do + assert read!(id, :tenant_post_count, tenant: "acme").tenant_post_count == 1 + assert read!(id, :tenant_post_count, tenant: "missing").tenant_post_count == 0 + end + + test "intermediate preparation receives the actor", %{author_id: id} do + assert read!(id, :actor_comment_count, actor: %{title: "alice"}).actor_comment_count == 1 + assert read!(id, :actor_comment_count, actor: %{title: "bob"}).actor_comment_count == 2 + end + + test "intermediate preparation receives the tenant", %{author_id: id} do + assert read!(id, :tenant_comment_count, tenant: "acme").tenant_comment_count == 1 + assert read!(id, :tenant_comment_count, tenant: "other").tenant_comment_count == 2 + end + + test "relationship context reaches intermediate read preparations", %{author_id: id} do + assert [_] = + Post + |> Ash.Query.set_context(%{visible_title: "alice"}) + |> Ash.Query.for_read(:for_context) + |> Ash.read!() + + assert read!(id, :context_comment_count, []).context_comment_count == 1 + end + + test "relationship arguments reach intermediate read actions", %{author_id: id} do + assert [_] = Post |> Ash.Query.for_read(:for_argument, %{title: "alice"}) |> Ash.read!() + assert read!(id, :argument_comment_count, []).argument_comment_count == 1 + end + + test "many-to-many counts honor the join relationship read action" do + source = Ash.Seed.seed!(%AshSqlite.Test.Post{title: "source"}) + + for state <- [:active, :archived] do + target = Ash.Seed.seed!(%AshSqlite.Test.Post{title: to_string(state)}) + + Ash.Seed.seed!(%AshSqlite.Test.PostLink{ + source_post_id: source.id, + destination_post_id: target.id, + state: state + }) + end + + assert [_] = + PostLink + |> Ash.Query.for_read(:for_actor, %{}, actor: %{title: "active"}) + |> Ash.read!() + + result = + Post + |> Ash.Query.filter(id == ^source.id) + |> Ash.Query.aggregate(:visible_count, :count, :linked_posts) + |> Ash.read_one!(actor: %{title: "active"}) + + assert result.aggregates.visible_count == 1 + end + + test "shared parent context reaches intermediate preparations", %{author_id: id} do + result = + Author + |> Ash.Query.filter(id == ^id) + |> Ash.Query.set_context(%{shared: %{visible_title: "alice"}}) + |> Ash.Query.load(:source_context_comment_count) + |> Ash.read_one!() + + assert result.source_context_comment_count == 1 + end + + defp read!(id, aggregate, opts) do + Author |> Ash.Query.filter(id == ^id) |> Ash.Query.load(aggregate) |> Ash.read_one!(opts) + end + + defp post!(author, title, category, comments) do + post = + Ash.Seed.seed!(%AshSqlite.Test.Post{ + title: title, + category: category, + author_id: author.id + }) + + for _ <- 1..comments do + Ash.Seed.seed!(%AshSqlite.Test.Comment{title: "comment", post_id: post.id}) + end + end +end diff --git a/test/aggregate_relationship_test.exs b/test/aggregate_relationship_test.exs new file mode 100644 index 0000000..92ef337 --- /dev/null +++ b/test/aggregate_relationship_test.exs @@ -0,0 +1,325 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.AggregateRelationshipTest do + use AshSqlite.RepoCase, async: false + require Ash.Query + + defmodule Comment do + use Ash.Resource, + domain: AshSqlite.AggregateRelationshipTest.Domain, + data_layer: AshSqlite.DataLayer + + sqlite do + table("comments") + repo(AshSqlite.TestRepo) + end + + attributes do + uuid_primary_key(:id) + attribute(:post_id, :uuid) + attribute(:title, :string) + attribute(:likes, :integer) + end + + actions do + defaults([:read]) + end + end + + defmodule Post do + use Ash.Resource, + domain: AshSqlite.AggregateRelationshipTest.Domain, + data_layer: AshSqlite.DataLayer + + sqlite do + table("posts") + repo(AshSqlite.TestRepo) + end + + attributes do + uuid_primary_key(:id) + attribute(:author_id, :uuid) + attribute(:title, :string) + attribute(:category, :string) + end + + actions do + defaults([:read]) + end + + relationships do + has_many(:comments, Comment, destination_attribute: :post_id) + + has_many :top_comments, Comment do + destination_attribute(:post_id) + sort(likes: :desc) + limit(2) + end + + has_many(:links, AshSqlite.AggregateRelationshipTest.TenantLink, + destination_attribute: :source_post_id + ) + + many_to_many :linked_posts, __MODULE__ do + through(AshSqlite.AggregateRelationshipTest.TenantLink) + join_relationship(:links) + source_attribute_on_join_resource(:source_post_id) + destination_attribute_on_join_resource(:destination_post_id) + end + end + + aggregates do + count :top_count_matching, :top_comments do + filter(expr(likes == 1)) + end + + first :top_first_matching, :top_comments, :likes do + filter(expr(likes == 1)) + end + + list :top_list_matching, :top_comments, :likes do + filter(expr(likes == 1)) + end + end + end + + defmodule TenantPost do + use Ash.Resource, + domain: AshSqlite.AggregateRelationshipTest.Domain, + data_layer: AshSqlite.DataLayer + + sqlite do + table("posts") + repo(AshSqlite.TestRepo) + end + + attributes do + uuid_primary_key(:id) + attribute(:author_id, :uuid) + attribute(:title, :string) + attribute(:category, :string) + end + + multitenancy do + strategy(:attribute) + attribute(:category) + end + + actions do + defaults([:read]) + end + + relationships do + has_many(:comments, Comment, destination_attribute: :post_id) + end + end + + defmodule TenantLink do + use Ash.Resource, + domain: AshSqlite.AggregateRelationshipTest.Domain, + data_layer: AshSqlite.DataLayer + + sqlite do + table("post_links") + repo(AshSqlite.TestRepo) + end + + attributes do + attribute(:source_post_id, :uuid, primary_key?: true, allow_nil?: false) + attribute(:destination_post_id, :uuid, primary_key?: true, allow_nil?: false) + attribute(:state, :string) + end + + multitenancy do + strategy(:attribute) + attribute(:state) + end + + actions do + defaults([:read]) + end + end + + defmodule Author do + use Ash.Resource, + domain: AshSqlite.AggregateRelationshipTest.Domain, + data_layer: AshSqlite.DataLayer + + sqlite do + table("authors") + repo(AshSqlite.TestRepo) + end + + attributes do + uuid_primary_key(:id) + end + + actions do + defaults([:read]) + end + + relationships do + has_many(:tenant_posts, TenantPost, destination_attribute: :author_id) + end + + aggregates do + count(:tenant_post_count, :tenant_posts) + count(:tenant_comment_count, [:tenant_posts, :comments]) + end + end + + defmodule Domain do + use Ash.Domain, validate_config_inclusion?: false + + resources do + resource(Author) + resource(Post) + resource(Comment) + resource(TenantPost) + resource(TenantLink) + end + end + + setup do + author = Ash.Seed.seed!(%AshSqlite.Test.Author{first_name: "deeper", last_name: "review"}) + + a = + Ash.Seed.seed!(%AshSqlite.Test.Post{title: "alice", category: "acme", author_id: author.id}) + + b = + Ash.Seed.seed!(%AshSqlite.Test.Post{title: "bob", category: "other", author_id: author.id}) + + for {post, likes} <- [{a, 1}, {b, 2}, {b, 3}] do + Ash.Seed.seed!(%AshSqlite.Test.Comment{title: "comment", post_id: post.id, likes: likes}) + end + + %{author: author, a: a, b: b} + end + + test "single-hop attribute tenant scope control", %{author: author} do + assert [_] = TenantPost |> Ash.Query.set_tenant("acme") |> Ash.read!() + + assert %{tenant_post_count: 1} = + Author + |> Ash.Query.filter(id == ^author.id) + |> Ash.Query.load(:tenant_post_count) + |> Ash.read_one!(tenant: "acme") + end + + test "intermediate attribute tenancy scopes rows", %{author: author} do + assert [_] = TenantPost |> Ash.Query.set_tenant("acme") |> Ash.read!() + + assert %{tenant_comment_count: 1} = + Author + |> Ash.Query.filter(id == ^author.id) + |> Ash.Query.load(:tenant_comment_count) + |> Ash.read_one!(tenant: "acme") + end + + test "aggregate tenant bypass does not change a sibling's tenant scope", %{author: author} do + result = + Author + |> Ash.Query.filter(id == ^author.id) + |> Ash.Query.load(:tenant_post_count) + |> Ash.Query.aggregate(:all_count, :count, :tenant_posts, multitenancy: :bypass) + |> Ash.read_one!(tenant: "acme") + + assert result.tenant_post_count == 1 + assert result.aggregates.all_count == 2 + end + + test "aggregate tenant bypass includes intermediate resources", %{author: author} do + result = + Author + |> Ash.Query.filter(id == ^author.id) + |> Ash.Query.load(:tenant_comment_count) + |> Ash.Query.aggregate(:all_count, :count, [:tenant_posts, :comments], + multitenancy: :bypass + ) + |> Ash.read_one!(tenant: "acme") + + assert result.tenant_comment_count == 1 + assert result.aggregates.all_count == 3 + end + + test "aggregate tenant bypass includes through resources", %{a: source} do + for state <- [:active, :archived] do + target = Ash.Seed.seed!(%AshSqlite.Test.Post{title: to_string(state)}) + + Ash.Seed.seed!(%AshSqlite.Test.PostLink{ + source_post_id: source.id, + destination_post_id: target.id, + state: state + }) + end + + result = + Post + |> Ash.Query.filter(id == ^source.id) + |> Ash.Query.aggregate(:scoped_count, :count, :linked_posts) + |> Ash.Query.aggregate(:all_count, :count, :linked_posts, multitenancy: :bypass) + |> Ash.read_one!(tenant: "active") + + assert result.aggregates.scoped_count == 1 + assert result.aggregates.all_count == 2 + end + + test "many-to-many through attribute tenancy scopes rows", %{a: a} do + for state <- [:active, :archived] do + target = Ash.Seed.seed!(%AshSqlite.Test.Post{title: to_string(state)}) + + Ash.Seed.seed!(%AshSqlite.Test.PostLink{ + source_post_id: a.id, + destination_post_id: target.id, + state: state + }) + end + + assert [_] = TenantLink |> Ash.Query.set_tenant("active") |> Ash.read!() + + assert %{aggregates: %{visible_count: 1}} = + Post + |> Ash.Query.filter(id == ^a.id) + |> Ash.Query.aggregate(:visible_count, :count, :linked_posts) + |> Ash.read_one!(tenant: "active") + end + + test "limited relationship scalar filter control", %{b: b} do + assert %{top_count_matching: 0} = + Post + |> Ash.Query.filter(id == ^b.id) + |> Ash.Query.load(:top_count_matching) + |> Ash.read_one!() + end + + test "limited relationship first filter runs after limit", %{a: a} do + for likes <- [4, 5] do + Ash.Seed.seed!(%AshSqlite.Test.Comment{title: "top", post_id: a.id, likes: likes}) + end + + query = Post |> Ash.Query.filter(id == ^a.id) + + assert %{top_comments: [%{likes: 5}, %{likes: 4}]} = + query |> Ash.Query.load(:top_comments) |> Ash.read_one!() + + assert %{top_count_matching: 0} = + query |> Ash.Query.load(:top_count_matching) |> Ash.read_one!() + + assert %{top_first_matching: nil} = + query |> Ash.Query.load(:top_first_matching) |> Ash.read_one!() + end + + test "limited relationship list filter runs after limit", %{a: a} do + for likes <- [4, 5] do + Ash.Seed.seed!(%AshSqlite.Test.Comment{title: "top", post_id: a.id, likes: likes}) + end + + assert %{top_list_matching: []} = + Post + |> Ash.Query.filter(id == ^a.id) + |> Ash.Query.load(:top_list_matching) + |> Ash.read_one!() + end +end diff --git a/test/aggregate_scalar_type_test.exs b/test/aggregate_scalar_type_test.exs new file mode 100644 index 0000000..11587b8 --- /dev/null +++ b/test/aggregate_scalar_type_test.exs @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.AggregateScalarTypeTest do + use AshSqlite.RepoCase, async: false + + alias AshSqlite.Test.{Comment, Post} + + defmodule Quantity do + use Ash.Type + + defstruct [:value, :unit] + + @impl true + def constraints, do: [unit: [type: :atom, default: :units]] + + @impl true + def storage_type(_), do: :integer + + @impl true + def cast_input(value, constraints), do: cast_stored(value, constraints) + + @impl true + def cast_stored(nil, _), do: {:ok, nil} + + def cast_stored(value, constraints) when is_integer(value) do + {:ok, %__MODULE__{value: value, unit: constraints[:unit]}} + end + + def cast_stored(%__MODULE__{} = value, _), do: {:ok, value} + def cast_stored(_, _), do: :error + + @impl true + def dump_to_native(%__MODULE__{value: value}, _), do: {:ok, value} + def dump_to_native(value, _) when is_integer(value) or is_nil(value), do: {:ok, value} + end + + test "loaded scalar aggregates use their declared type and constraints" do + post = Ash.Seed.seed!(%Post{title: "scalar types"}) + Ash.Seed.seed!(%Comment{post_id: post.id, likes: 3}) + Ash.Seed.seed!(%Comment{post_id: post.id, likes: 4}) + + result = + Post + |> Ash.Query.aggregate(:total, :sum, :comments, + field: :likes, + type: Quantity, + constraints: [unit: :points] + ) + |> Ash.read_one!() + + assert result.aggregates.total == %Quantity{value: 7, unit: :points} + end + + test "loaded scalar defaults use the same constrained type" do + Ash.Seed.seed!(%Post{title: "empty scalar"}) + + result = + Post + |> Ash.Query.aggregate(:total, :sum, :comments, + field: :likes, + default: 0, + type: Quantity, + constraints: [unit: :points] + ) + |> Ash.read_one!() + + assert result.aggregates.total == %Quantity{value: 0, unit: :points} + end +end diff --git a/test/aggregate_scope_test.exs b/test/aggregate_scope_test.exs new file mode 100644 index 0000000..6e6bc93 --- /dev/null +++ b/test/aggregate_scope_test.exs @@ -0,0 +1,139 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.AggregateScopeTest do + use AshSqlite.RepoCase, async: false + require Ash.Query + + defmodule ScopeFromContext do + use Ash.Resource.Preparation + + @impl true + def prepare(query, _opts, _context) do + Ash.Query.do_filter(query, title: query.context[:visible_title] || "missing-context") + end + end + + defmodule Comment do + use Ash.Resource, domain: AshSqlite.AggregateScopeTest.Domain, data_layer: AshSqlite.DataLayer + + sqlite do + table("comments") + repo(AshSqlite.TestRepo) + end + + attributes do + uuid_primary_key(:id) + attribute(:post_id, :uuid) + attribute(:title, :string) + attribute(:likes, :integer) + end + + actions do + defaults([:read]) + + read :visible do + filter(expr(title == "visible")) + end + + read :by_title do + argument(:title, :string, allow_nil?: false) + filter(expr(title == ^arg(:title))) + end + + read :from_context do + prepare(ScopeFromContext) + end + end + end + + defmodule Post do + use Ash.Resource, domain: AshSqlite.AggregateScopeTest.Domain, data_layer: AshSqlite.DataLayer + + sqlite do + table("posts") + repo(AshSqlite.TestRepo) + end + + attributes do + uuid_primary_key(:id) + end + + actions do + defaults([:read]) + end + + relationships do + has_many :top, Comment do + destination_attribute(:post_id) + read_action(:visible) + sort(likes: :desc) + limit(2) + end + end + + aggregates do + list(:top_likes, :top, :likes) + + first :least_top_likes, :top, :likes do + sort(likes: :asc) + end + + count(:top_count, :top) + end + end + + defmodule Domain do + use Ash.Domain, validate_config_inclusion?: false + + resources do + resource(Post) + resource(Comment) + end + end + + setup do + post = Ash.Seed.seed!(%AshSqlite.Test.Post{title: "endpoint review"}) + + for {title, likes} <- [{"hidden", 10}, {"visible", 9}, {"visible", 8}] do + Ash.Seed.seed!(%AshSqlite.Test.Comment{post_id: post.id, title: title, likes: likes}) + end + + %{query: Post |> Ash.Query.filter(id == ^post.id)} + end + + test "list preserves endpoint action scope before relationship limit", %{query: query} do + assert %{top: [%{likes: 9}, %{likes: 8}]} = query |> Ash.Query.load(:top) |> Ash.read_one!() + assert %{top_likes: [9, 8]} = query |> Ash.Query.load(:top_likes) |> Ash.read_one!() + end + + test "first preserves endpoint action scope before relationship limit", %{query: query} do + assert %{least_top_likes: 8} = query |> Ash.Query.load(:least_top_likes) |> Ash.read_one!() + end + + test "scalar endpoint action scope before relationship limit", %{query: query} do + assert %{top_count: 2} = query |> Ash.Query.load(:top_count) |> Ash.read_one!() + end + + test "a prepared aggregate query retains its action and arguments", %{query: query} do + aggregate_query = Ash.Query.for_read(Comment, :by_title, %{title: "visible"}) + + assert %{aggregates: %{visible_count: 2}} = + query + |> Ash.Query.aggregate(:visible_count, :count, :top, query: aggregate_query) + |> Ash.read_one!() + end + + test "a prepared aggregate query retains its preparation context", %{query: query} do + aggregate_query = + Comment + |> Ash.Query.set_context(%{visible_title: "visible"}) + |> Ash.Query.for_read(:from_context) + + assert %{aggregates: %{visible_count: 2}} = + query + |> Ash.Query.aggregate(:visible_count, :count, :top, query: aggregate_query) + |> Ash.read_one!() + end +end From 46a4b869450a2a961ef9af44b5b69da2d5aff29c Mon Sep 17 00:00:00 2001 From: Will Townsend Date: Thu, 24 Sep 2026 21:22:01 -0700 Subject: [PATCH 14/14] test(aggregates): cover loaded distinct counts without a single key Loaded fieldless counts over composite-key and keyless destinations must return an unsupported error rather than a collapsed count, for both explicit uniq? and to-many filter fanout. Exists over the same fanout filter still loads. --- test/aggregate_test.exs | 71 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/test/aggregate_test.exs b/test/aggregate_test.exs index 785076b..5357c6a 100644 --- a/test/aggregate_test.exs +++ b/test/aggregate_test.exs @@ -525,6 +525,77 @@ defmodule AshSqlite.AggregatesTest do Ash.load!(post, :count_of_comments_with_popular_ratings) end + describe "loaded fieldless distinct counts" do + setup do + post = create_post!("distinct count source") + + destinations = + for index <- 1..3 do + destination = create_post!("distinct count destination #{index}") + create_comment!(destination, "match", 1) + create_comment!(destination, "match", 1) + destination + end + + link_posts!(post, destinations) + + %{post: post} + end + + test "reject composite primary keys", %{post: post} do + assert_raise Ash.Error.Unknown, + ~r/requires a single primary key.*composite primary key/, + fn -> + Post + |> Ash.Query.filter(id == ^post.id) + |> Ash.Query.aggregate(:link_count, :count, :post_links, uniq?: true) + |> Ash.read_one!() + end + end + + test "over to-many filter refs reject composite primary keys", %{post: post} do + assert_raise Ash.Error.Unknown, + ~r/requires a single primary key.*composite primary key/, + fn -> + Post + |> Ash.Query.filter(id == ^post.id) + |> Ash.Query.aggregate(:link_count, :count, :post_links, + query: [filter: [destination_post: [comments: [title: "match"]]]] + ) + |> Ash.read_one!() + end + end + + test "reject resources without primary keys", %{post: post} do + for _ <- 1..3 do + PostView + |> Ash.Changeset.for_action(:create, %{browser: :firefox, post_id: post.id}) + |> Ash.create!() + end + + assert_raise Ash.Error.Unknown, ~r/requires a single primary key.*has no primary key/, fn -> + Post + |> Ash.Query.filter(id == ^post.id) + |> Ash.Query.aggregate(:view_count, :count, :views, uniq?: true) + |> Ash.read_one!() + end + end + + test "do not affect exists over to-many filter refs", %{post: post} do + linked_id = post.id + unlinked_id = create_post!("distinct count unlinked").id + + assert %{^linked_id => true, ^unlinked_id => false} = + Post + |> Ash.Query.filter(id in [^linked_id, ^unlinked_id]) + |> Ash.Query.aggregate(:has_link, :exists, :post_links, + query: [filter: [destination_post: [comments: [title: "match"]]]] + ) + |> Ash.read!() + |> Map.new(&{&1.id, &1.aggregates.has_link}) + end + end + test "exists filters avoid to-many fanout for sum aggregates" do post = create_post!("exists fanout aggregate filter") popular_comment = create_comment!(post, "popular", 4)