Aggregation: GROUP BY and HAVING
How GROUP BY splits rows into groups and HAVING filters them after counting.
An aggregate function turns many rows into one value. count(*) counts rows,
sum(weight) adds up a column, max(weight) finds the largest. With GROUP BY,
the rows are first split into groups and each group gets its own answer:
SELECT species, count(*), avg(weight)
FROM fish
GROUP BY species;The Aggregate step
The plan gets an Aggregate step between the filter and the projection:
Project species, count(*), avg(weight)
Aggregate count(*), avg(weight) GROUP BY species
SeqScan fishAggregate reads every input row, works out which group it belongs to, and
feeds it to that group's accumulators. An accumulator is a small running
total: count keeps a number, sum keeps a running sum, max keeps the
largest value so far. When the input runs out, each accumulator is finished
into one value and Aggregate returns one row per group.
Like Sort, Aggregate is blocking: no group is complete until the last row has been read. Groups come out ordered by their GROUP BY values, which is the order SQLite produces too.
Sometimes the plan says GROUP BY species DESC. When ORDER BY has as many
terms as GROUP BY, SQLite orders the groups in the ORDER BY's direction, hoping
the final sort has less to do. Groups that tie in ORDER BY keep that order, so
Fishbowl copies it to give the same answer.
WHERE runs before grouping, HAVING after
WHERE weight > 1 drops rows before they reach Aggregate. HAVING count(*) > 1
drops whole groups after. That is why HAVING can use aggregates and WHERE
cannot: when WHERE runs, there are no groups to count yet. In the plan, HAVING
is a second Filter step above Aggregate.
Details that follow SQLite
- No rows in, one row out.
SELECT count(*) FROM fish WHERE 0returns one row with 0, because without GROUP BY the whole table is one group, even when it is empty.sumof no rows is NULL.totalof no rows is 0.0. - NULLs are skipped.
count(weight)counts only rows where weight is not NULL.count(*)counts every row. - sum stays exact as long as it can. While every value is an integer, sum adds 64-bit integers and fails loudly with "integer overflow" if they overflow. As soon as a real number appears, it switches to floating point.
- Plain columns are allowed. SQLite accepts
SELECT species, name FROM fish GROUP BY specieseven thoughnameis not grouped. Each group shows the name from its first row. With exactly onemin()ormax(), it shows the row that holds the minimum or maximum instead, soSELECT name, max(weight)names the heaviest fish. - DISTINCT changes which row min() and max() show.
max(DISTINCT x)returns the same value asmax(x), but SQLite skips a repeated value without looking at it, and the plain columns then do whatever the row before did. If that row was the new maximum, a later tie takes over; otherwise it is ignored. So with x = 1, 3, 3 the plain columns come from the second 3, wheremax(x)would show the first. A repeated NULL counts as a repeat too.
Try the "groups" example in the UI.
Code: crates/engine/src/expr/aggregate.rs (the accumulators),
crates/engine/src/exec.rs (aggregate), and crates/engine/src/planner.rs,
which finds the aggregates in a query and decides whether it is an aggregate
query at all.