Skip to content

Conversation

@sevenseacat
Copy link
Contributor

@sevenseacat sevenseacat commented Jan 25, 2026

This is one I came across while testing out combinations of different options in the Cinder demo app. A PR for a fix is in ash-project/ash_sql#206.

When all three of these are true:

  • an aggregate is being loaded
  • the query is being sorted by a relationship sort
  • the query has keyset pagination applied

Then generating the query explodes with an error:

  1) test keyset pagination with aggregates and relationship sort keyset pagination with loaded aggregate and relationship sort works (AshPostgres.SortTest)
     test/sort_test.exs:299
     ** (Ash.Error.Unknown)
     Bread Crumbs:
       > Error returned from: AshPostgres.Test.Post.keyset

     Unknown Error

     * ** (Ecto.SubQueryError) the following exception happened when compiling a subquery.

         ** (Ecto.QueryError) atoms, structs, maps, lists, tuples and sources are not allowed as map values in subquery, got: `%{
           calculations: %{
             __calc__0:
               type(
                 as(1).first_name(),
                 {:parameterized,
                  {AshPostgres.Type.StringWrapper.EctoType, allow_empty?: true, trim?: false}}
               )
           },
           not_selected_by_default: &0.not_selected_by_default()
         }` in query:

         from p0 in AshPostgres.Test.Post,
           as: 0,
           join: a1 in AshPostgres.Test.Author,
           on: as(0).author_id == a1.id,
           where: type(as(0).type, {:parameterized, {Ash.Type.Atom.EctoType, unsafe_to_atom?: false}}) ==
           type(^"sponsored", {:parameterized, {Ash.Type.Atom.EctoType, []}}),
           order_by: [
           asc:
             type(
               as(1).first_name,
               {:parameterized,
                {AshPostgres.Type.StringWrapper.EctoType, allow_empty?: true, trim?: false}}
             ),
           asc: as(0).id
         ],
           limit: ^11,
           select: merge(
           struct(p0, [
             :uniq_two,
             :ltree_unescaped,
             :db_point_id,
             :created_at,
             :model,
             :version,
             :base_reading_time,
             :score,
             :organization_id,
             :category,
             :stuff,
             :author_id,
             :price,
             :uniq_on_upper,
             :uniq_if_contains_foo,
             :ltree_escaped,
             :uniq_custom_one,
             :person_detail,
             :updated_at,
             :is_special,
             :db_string_point_id,
             :status,
             :metadata,
             :list_containing_nils,
             :parent_post_id,
             :uniq_custom_two,
             :point,
             :datetime,
             :list_of_stuff,
             :composite_point,
             :status_enum_no_cast,
             :id,
             :title,
             :status_enum,
             :string_point,
             :uniq_one,
             :public,
             :type,
             :limited_score,
             :decimal,
             :constrained_int
           ]),
           %{
             calculations: %{
               __calc__0:
                 type(
                   as(1).first_name,
                   {:parameterized,
                    {AshPostgres.Type.StringWrapper.EctoType, allow_empty?: true, trim?: false}}
                 )
             },
             not_selected_by_default: p0.not_selected_by_default
           }
         )


     The subquery originated from the following query:

     from p0 in subquery(from p0 in AshPostgres.Test.Post,
       as: 0,
       join: a1 in ^#Ecto.Query<from a0 in AshPostgres.Test.Author, as: 500>,
       as: 1,
       on: as(0).author_id == a1.id,
       where: type(as(0).type, {:parameterized, {Ash.Type.Atom.EctoType, unsafe_to_atom?: false}}) ==
       type(^"sponsored", {:parameterized, {Ash.Type.Atom.EctoType, []}}),
       order_by: [
       asc:
         type(
           as(1).first_name,
           {:parameterized,
            {AshPostgres.Type.StringWrapper.EctoType, allow_empty?: true, trim?: false}}
         ),
       asc: as(0).id
     ],
       limit: ^11,
       select: merge(
       struct(p0, [
         :uniq_two,
         :ltree_unescaped,
         :db_point_id,
         :created_at,
         :model,
         :version,
         :base_reading_time,
         :score,
         :organization_id,
         :category,
         :stuff,
         :author_id,
         :price,
         :uniq_on_upper,
         :uniq_if_contains_foo,
         :ltree_escaped,
         :uniq_custom_one,
         :person_detail,
         :updated_at,
         :is_special,
         :db_string_point_id,
         :status,
         :metadata,
         :list_containing_nils,
         :parent_post_id,
         :uniq_custom_two,
         :point,
         :datetime,
         :list_of_stuff,
         :composite_point,
         :status_enum_no_cast,
         :id,
         :title,
         :status_enum,
         :string_point,
         :uniq_one,
         :public,
         :type,
         :limited_score,
         :decimal,
         :constrained_int
       ]),
       %{
         calculations: %{
           __calc__0:
             type(
               as(1).first_name,
               {:parameterized,
                {AshPostgres.Type.StringWrapper.EctoType, allow_empty?: true, trim?: false}}
             )
         },
         not_selected_by_default: p0.not_selected_by_default
       }
     )),
       as: 0,
       left_lateral_join: c1 in ^#Ecto.Query<from c0 in subquery(from c0 in AshPostgres.Test.Comment,
       as: 2,
       where: parent_as(0).id == as(2).post_id,
       group_by: [c0.post_id],
       select: %{
       post_id: c0.post_id,
       count_of_comments:
         type(
           coalesce(count(), type(^0, {:parameterized, {Ash.Type.Integer.EctoType, []}})),
           {:parameterized, {Ash.Type.Integer.EctoType, []}}
         )
     }), as: 2>,
       as: 2,
       on: true,
       select: merge(
       struct(p0, [
         :uniq_two,
         :ltree_unescaped,
         :db_point_id,
         :created_at,
         :model,
         :version,
         :base_reading_time,
         :score,
         :organization_id,
         :category,
         :stuff,
         :author_id,
         :price,
         :uniq_on_upper,
         :uniq_if_contains_foo,
         :ltree_escaped,
         :uniq_custom_one,
         :person_detail,
         :updated_at,
         :is_special,
         :db_string_point_id,
         :status,
         :metadata,
         :list_containing_nils,
         :parent_post_id,
         :uniq_custom_two,
         :point,
         :datetime,
         :list_of_stuff,
         :composite_point,
         :status_enum_no_cast,
         :id,
         :title,
         :status_enum,
         :string_point,
         :uniq_one,
         :public,
         :type,
         :limited_score,
         :decimal,
         :constrained_int,
         :calculations
       ]),
       %{
         count_of_comments:
           type(
             coalesce(
               type(as(2).count_of_comments, {:parameterized, {Ash.Type.Integer.EctoType, []}}),
               type(^0, {:parameterized, {Ash.Type.Integer.EctoType, []}})
             ),
             {:parameterized, {Ash.Type.Integer.EctoType, []}}
           )
       }
     )

       (ecto 3.13.5) lib/ecto/repo/queryable.ex:223: Ecto.Repo.Queryable.execute/4
       (ecto 3.13.5) lib/ecto/repo/queryable.ex:19: Ecto.Repo.Queryable.all/3
       (ash_postgres 2.6.27) lib/data_layer.ex:842: anonymous fn/3 in AshPostgres.DataLayer.run_query/2
       (ash_postgres 2.6.27) lib/data_layer.ex:841: AshPostgres.DataLayer.run_query/2
       (ash 3.11.3) lib/ash/actions/read/read.ex:4150: Ash.Actions.Read.run_query/4
       (ash 3.11.3) lib/ash/actions/read/read.ex:806: anonymous fn/9 in Ash.Actions.Read.do_read/5
       (ash 3.11.3) lib/ash/actions/read/read.ex:1591: Ash.Actions.Read.maybe_in_transaction/3
       (ash 3.11.3) lib/ash/actions/read/read.ex:436: Ash.Actions.Read.do_run/3
       (ash 3.11.3) lib/ash/actions/read/read.ex:90: anonymous fn/3 in Ash.Actions.Read.run/3
       (ash 3.11.3) lib/ash/actions/read/read.ex:89: Ash.Actions.Read.run/3
       (ash 3.11.3) lib/ash.ex:2782: Ash.read/2
       (ash 3.11.3) lib/ash.ex:2717: Ash.read!/2
       test/sort_test.exs:348: AshPostgres.SortTest."test keyset pagination with aggregates and relationship sort keyset pagination with loaded aggregate and relationship sort works"/1
       (ex_unit 1.18.4) lib/ex_unit/runner.ex:511: ExUnit.Runner.exec_test/2
       (ex_unit 1.18.4) lib/ex_unit/capture_log.ex:113: ExUnit.CaptureLog.with_log/2
       (ex_unit 1.18.4) lib/ex_unit/runner.ex:460: anonymous fn/3 in ExUnit.Runner.maybe_capture_log/3
       (stdlib 6.1.2) timer.erl:590: :timer.tc/2
       (ex_unit 1.18.4) lib/ex_unit/runner.ex:433: anonymous fn/6 in ExUnit.Runner.spawn_test_monitor/4

Contributor checklist

Leave anything that you believe does not apply unchecked.

  • I accept the AI Policy, or AI was not used in the creation of this PR.
  • Bug fixes include regression tests
  • Chores
  • Documentation changes
  • Features include unit/acceptance tests
  • Refactoring
  • Update dependencies

sevenseacat added a commit to ash-project/ash_sql that referenced this pull request Jan 25, 2026
…query for aggregates

When keyset pagination is combined with a relationship sort and loaded
aggregates, the query's select contains nested maps like
`%{calculations: %{__calc__0: expr}}`. Ecto doesn't allow nested maps
in subquery selects, causing an error.

This fix reuses `AshSql.Query.rewrite_nested_selects/1` (already used in
`distinct.ex` and `query.ex` for the same purpose) to flatten these
nested maps before creating the subquery in `wrap_in_subquery_for_aggregates`.

Fixes the failing test in ash-project/ash_postgres#677
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants