Dynamically replace an Ecto `select` with a modified version of it

I’m looking for a way to extend an existing select on an Ecto query. I’d like to wrap any existing select inside a tuple such that we preserve the original selection while also selecting another set of fields.

My goal is to allow my keyset pagination library (Chunkr) to not only honor the fields already selected by the passed-in query but to also automatically select additional fields needed to generate a cursor. In practice, the fields selected by the original query and the additional fields selected by Chunkr may or may not overlap.

Effectively, what I’m looking to implement is a function in the spirit of:

def apply_select(query, cursor_fields) do
  case query do
    %{select: nil} -> 
      Ecto.Query.select(query, [record], {cursor_fields, record})
      
    %{select: existing_select} -> 
      Ecto.Query.select(query, _, {cursor_fields, existing_select})
  end
end

That example won’t work, mostly because the :select field on an Ecto.Query (i.e. the existing_select above) is an Ecto-specific implementation detail—it’s not the raw select as provided by the original creator of the query.

For what it’s worth, I believe I could do what I need via select_merge, but only if I force users of my library to always use a map for the top-level of their select. I’d prefer to more elegantly enable “any” Ecto query to be paginated.

Is anyone familiar enough with Ecto to know whether this is possible?

Not sure it’s possible, docs on ecto Ecto.Query — Ecto v3.7.1 indicate that select is not considered composable and select_merge should be used if composability needed. It seems to me select is way to flexible in feature to be easily composable (what happened if select is list? or if it’s a struct?) : The doc hints at exclude/2 though, if you could somehow exclude current select, get their data for selection, and merge it as new select (which seems hard).

There can only be one select expression in a query, if the select expression is omitted, the query will by default select the full schema. If select is given more than once, an error is raised. Use exclude/2 if you would like to remove a previous select for overriding or see select_merge/3 for a limited version of select that is composable and can be called multiple times.

from(c in City, select: c) # returns the schema as a struct
from(c in City, select: {c.name, c.population})
from(c in City, select: [c.name, c.county])
from(c in City, select: %{n: c.name, answer: 42})
from(c in City, select: %{c | alternative_name: c.name})
from(c in City, select: %Data{name: c.name})