Skip to content

Add @summarize (alias @summarise) for grouped and whole-table aggregation - #356

Open
davidanthoff wants to merge 3 commits into
mainfrom
add-summarize
Open

Add @summarize (alias @summarise) for grouped and whole-table aggregation#356
davidanthoff wants to merge 3 commits into
mainfrom
add-summarize

Conversation

@davidanthoff

@davidanthoff davidanthoff commented Sep 1, 2026

Copy link
Copy Markdown
Member

Adds a dplyr-style @summarize standalone macro, closing the biggest ergonomic gap identified in the recent API audit: grouped aggregation currently requires the two-step @groupby(_.k) |> @map({k = key(_), m = mean(_.x)}) idiom with the explicit key(_) dance.

Depends on queryverse/QueryOperators.jl#54 — the operator layer lives there, and this PR sets QueryOperators = "1.1" compat, so CI here stays red until QueryOperators 1.1.0 is merged and registered.

Syntax

df |> @groupby(_.k) |> @summarize(m = mean(_.x), n = length(_))

Each argument must have the form name = expression. Inside each expression _ is the collection of rows being aggregated, so mean(_.x), length(_) and key(_) all work. Both the pipe form and the experimental source-first form (@summarize(source, name = expr, ...)) are supported, mirroring every other standalone macro.

Semantics (dplyr's, in both cases)

  • Grouped input (eltype <: Grouping): one output row per group, with the grouping key columns prepended and the aggregates after. The result stays a lazy stream — the grouped path is literally a QueryOperators.map over the groups, so it works on every source @map works on and remains representable in backend query plans as groupby+map.
  • Ungrouped input: exactly one output row, with no key columns — the whole source is treated as a single keyless group. The result is still a stream (of one row), never a bare scalar, so @summarize stays a table→table arrow that composes with everything downstream (|> @mutate(...), |> DataFrame, ...); users who want the bare named tuple write |> first. (The terminal-scalar design already exists as @count and is deliberately not duplicated.)
  • Scalar grouping key: becomes a result column literally named key (rename afterwards with @rename if desired). A multi-column key from @groupby({_.a, _.b}) splats its fields as columns a, b, ... in order.
  • Name collisions: if an aggregate is named like a key column, merge is left-to-right, so the aggregate wins — documented and tested, not an error.
  • Errors: @summarize() with no aggregates, or any argument that is not name = expression, throws a macro-expansion-time error naming the problem (deliberately stricter than @mutate).

Implementation

The operator layer — QueryOperators.summarize with its eltype dispatch, the lazy one-row EnumerableSummarizeAll for the ungrouped path, the keyless-Grouping sentinel and the _key_namedtuple key normalization — lives in QueryOperators (queryverse/QueryOperators.jl#54), alongside the other enumerables and with the generic function open for backend methods. This PR contributes the macro layer: it emits one lambda via the existing helper_replace_anon_func_syntax pass (including @mutate's closure-escaping approach) and expands to QueryOperators.summarize(QueryOperators.query(source), f, f_expr).

  • grouped → QueryOperators.map(source, f, f_expr) (lazy, unchanged rows-per-group semantics);
  • ungrouped → a lazy one-element enumerable that, on first iterate, collects the rows and wraps them in a keyless Grouping (keyed by a private sentinel type, so a legitimate nothing grouping key stays unambiguous), then yields the single aggregated row. Eager materialization on first iterate has precedent in orderby/groupby.

Reusing Grouping for the ungrouped path means _.x column access goes through the existing GroupColumnArrayView machinery identically in both paths, and plain dispatch on the key type keeps both paths type-stable by construction, so Base._return_type-based eltype inference downstream still yields concrete row types.

A representative macroexpansion of @summarize(m = mean(_.x), n = length(_)) (gensyms renamed for readability; QO = Query.QueryOperators, reached through the Query module because the expansion is fully escaped and users load Query, not QueryOperators):

source -> QO.summarize(QO.query(source),
    g -> Base.merge(QO._key_namedtuple(QO.key(g)),
                    (; m = mean(g.x), n = length(g))),
    :(g -> Base.merge(QO._key_namedtuple(QO.key(g)),
                      (; m = mean(g.x), n = length(g)))))

where the key normalization in QueryOperators is:

_key_namedtuple(k::NamedTuple) = k                        # multi-column key: splat fields
_key_namedtuple(::_SummarizeUngroupedKey) = NamedTuple()  # ungrouped: no key columns
_key_namedtuple(k) = (key = k,)                           # scalar key: column named `key`

Testing and docs

  • New test/test_summarize.jl testitems covering: scalar and multi-column keys, length(_)/key(_) in aggregates, both collision cases, chaining after @summarize, ungrouped single-row semantics (including empty sources and composability), closures over locals, concrete-eltype type stability for both paths, DataFrame collection, the source-first form, and the new error messages. Full testitem suites of both this PR and the QueryOperators PR pass (28/28 combined), and doctest(Query) passes on Julia 1.12 against the dev QueryOperators.
  • New @summarize section in docs/src/standalonequerycommands.md with jldoctest examples (grouped and ungrouped), plus a NEWS.md entry under a new v1.2.0 heading.

Backend pushdown follow-up (not in this PR)

SQL pushdown of @summarize is a separate follow-up in the backend repos: @groupby(_.k) without an element selector emits the simple 3-arg groupby, and QuerySQLite only registers the 5-arg form; QueryDuckDB would additionally need a translation branch for _key_namedtuple/key. The grouped path is deliberately just a QueryableMap after a groupby, so it stays fully representable in QueryableBackend plans; the ungrouped path can later be exposed to plan backends by overloading QueryOperators.summarize(::Queryable, ...) (SQL's aggregate without GROUP BY) — now possible because the generic function lives in QueryOperators. The fused group-and-reduce fast path is likewise out of scope: this sugar emits exactly the groupby+map pattern a future optimizing backend can pattern-match.

🤖 Generated with Claude Code

davidanthoff and others added 2 commits August 31, 2026 23:30
Adds a dplyr-style @summarize standalone macro (with @summarise alias)
that aggregates a grouped source into one row per group, prepending the
grouping key columns, or an ungrouped source into a single-row stream
with no key columns. Pure Query.jl-level sugar: the grouped path expands
to a lazy map over the groups, the ungrouped path wraps the source rows
in an internal keyless Grouping so column access works identically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The @summarize macro now expands to QueryOperators.summarize (added in
queryverse/QueryOperators.jl#54); the EnumerableSummarizeAll type, the
keyless-Grouping sentinel and the key normalization helpers move there.
Requires QueryOperators 1.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant