Skip to content

Expose the record count on the on_query_change payload - #208

Merged
sevenseacat merged 1 commit into
sevenseacat:mainfrom
mbaertschi:expose-count-on-query-change
Aug 26, 2026
Merged

Expose the record count on the on_query_change payload#208
sevenseacat merged 1 commit into
sevenseacat:mainfrom
mbaertschi:expose-count-on-query-change

Conversation

@mbaertschi

Copy link
Copy Markdown
Contributor

Expose the record count on the on_query_change payload

Why

A collection knows how many records match the current filters — it renders the
number in its own pagination footer. A parent LiveView cannot get at it. There
is no callback for it, and page lives on the LiveComponent's assigns.

So an app that wants to show the total anywhere else — a page header, a summary
line, a tab label — has to count the filter a second time:

def handle_info({:query_changed, %{query: query}}, socket) do
  count = query |> Ash.Query.unset([:load, :sort, :limit, :offset]) |> Ash.count!(actor: actor)
  {:noreply, assign(socket, :total, count)}
end

That is a full second COUNT over the same filter, and it's redundant:
QueryBuilder.execute/2 already passes count: true for both offset
(query_builder.ex:277) and keyset (:295) pagination, so Ash has computed
the number milliseconds earlier. The parent also has to dedupe the recount
itself, because on_query_change fires on paging and sorting too, neither of
which can change a count.

What

on_query_change now sends count alongside query:

{event_name, %{query: query, count: 431, id: "users-table"}}
def handle_info({:query_changed, %{count: count, id: "users-table"}}, socket) do
  {:noreply, assign(socket, :total, count)}
end

The value is read off the page that was just loaded, so it costs nothing and
always agrees with the pagination footer — it lands in the same render as the
rows, with no second query and nothing for the parent to dedupe.

  • %Ash.Page.Offset{} / %Ash.Page.Keyset{} → the count Ash returned, which
    may be nil if the read produced none. This keeps working if counting ever
    becomes opt-out, as suggested in Feature request: Optional count with keyset pagination #154.
  • An unpaginated read (execute_without_pagination/2 returns %{results: ...})
    length(results), which is the true total in that case since every matching
    record was loaded.
  • Anything else → nil.

Compatibility

Additive. The payload is a map, and existing handlers match on query and id,
so a new key breaks no consumer. Documented on both <Cinder.collection> and
<Cinder.Table.table>.

Docs

docs/advanced.md gains a short "Displaying the total elsewhere" subsection.

usage-rules.md had no on_query_change entry at all, so this adds a "Query
Access" section covering the callback and the count. That is the file coding
agents read, and its silence here is what leads to generated code that runs a
second Ash.count! over a query Cinder has already counted — which is exactly
the code this change removes. Happy to drop that hunk if you would rather keep
the PR to the payload alone.

No CHANGELOG entry — the Unreleased entries look maintainer-curated, with
attribution and PR links. Say the word and I will add one in whatever form you
prefer.

Tests

test/cinder/query_change_notification_test.exs covers the offset, keyset,
nil-count, and unpaginated cases, that the query is still delivered alongside
the count, and that nothing is sent when on_query_change is unset. Five of the
six fail without the change. Full suite green (1493 tests, 6 doctests).

Cinder already asks Ash for `count: true` on every load, for both offset
and keyset pagination, and renders the total in its own pagination footer.
A parent LiveView could not reach that number, so showing a total anywhere
else meant counting the same filter a second time.

Send the count Cinder already has alongside the query:

    {event_name, %{query: query, count: 431, id: "users-table"}}

Paginated reads report whatever Ash returned, which may be nil if the read
produced no count. An unpaginated read reports the number of records it
loaded, which is the true total in that case.

Additive: existing handlers match on `query` and `id`, so a new key in the
payload map breaks no consumer.
@sevenseacat

Copy link
Copy Markdown
Owner

I haven't figured out a nice way to automatically curate the changelog yet 😅 so for now yes, it's manually curated. I'll add an entry for this feature. Thank you so much for adding it! 🙌 ❤️

@sevenseacat
sevenseacat merged commit abf39c2 into sevenseacat:main Aug 26, 2026
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