Aggregations, GROUP BY and DISTINCT¶
Count, sum and group — typed, without writing SQL.
The problem¶
You want "how many orders per status" or "total revenue per region". That's
GROUP BY + aggregate functions. tempest-db-js exposes it with strong types: the
result is a row with the group columns (typed from the model) plus one field
per aggregate alias.
Counting rows¶
import { Model, column, select, count, createSyncEngine } from "tempest-db-js";
class Order extends Model {
static tablename = "orders";
id = column.integer().primaryKey();
status = column.text().notNull();
amount = column.integer().notNull();
}
const session = createSyncEngine("sqlite:///shop.db").session();
// Whole-table aggregate: pass [] as groupBy.
const total = session.execute(select(Order).aggregate([], { n: count() })).scalar();
// total: number
.scalar() for a single number
A whole-table aggregate returns one row. .scalar() grabs its first
value — perfect for a standalone COUNT.
Grouping¶
import { count, sum } from "tempest-db-js";
const rows = session
.execute(
select(Order)
.aggregate(["status"], { n: count(), total: sum("amount") })
.orderBy("status"),
)
.all();
// rows: { status: string; n: number; total: number | null }[]
The row has status (a group column, typed from the model) + n and total
(the aliases). count() is always number; sum/avg/min/max are
number | null (null when the group has no values).
Filtering before grouping¶
where comes before GROUP BY — it filters the rows that enter the groups:
select(Order)
.where({ amount: { gt: 0 } })
.aggregate(["status"], { total: sum("amount") });
// SELECT "status", SUM("amount") AS "total" FROM "orders" WHERE "amount" > ? GROUP BY "status"
The aggregators¶
| Helper | SQL | Result type |
|---|---|---|
count() |
COUNT(*) |
number |
sum("col") |
SUM(col) |
number \| null |
avg("col") |
AVG(col) |
number \| null |
min("col") |
MIN(col) |
number \| null |
max("col") |
MAX(col) |
number \| null |
min/max are for numeric columns
min/max return number | null. For non-numeric columns (text, dates)
the value comes back raw from the driver — handle the type yourself.
HAVING — filtering by the aggregate result¶
where filters rows, before grouping. To filter groups, by what the
aggregation produced, use .having():
const heavy = session
.execute(
select(Order)
.where({ status: "open" }) // (1)!
.aggregate(["customer"], { n: count(), total: sum("amount") })
.having({ n: { gt: 10 } }), // (2)!
)
.all();
// SELECT "customer", COUNT(*) AS "n", SUM("amount") AS "total" FROM "orders"
// WHERE "status" = $1 GROUP BY "customer" HAVING COUNT(*) > $2
- Filters the rows that enter the grouping.
- Filters the groups that come out of it. The keys are the aliases you named,
plus the
groupBycolumns.
.having() without .aggregate() is a compile error
The builder only gains the method after grouping, so the wrong order fails the type-check instead of becoming invalid SQL at runtime.
Why the SQL carries the expression, not the alias
PostgreSQL does not accept a SELECT alias in HAVING — HAVING n > 10
fails. The compiler re-emits the expression (COUNT(*) > $2), which works on
all three dialects. You still write the alias.
Ordering by the alias¶
ORDER BY, unlike HAVING, accepts the alias on every dialect — and the builder
emits it as written:
select(Order).aggregate(["customer"], { n: count() }).orderBy("n", "desc");
// ... GROUP BY "customer" ORDER BY "n" DESC
DISTINCT¶
To drop duplicate rows, .distinct():
const statuses = session
.execute(select(Order, ["status"]).distinct().orderBy("status"))
.all();
// SELECT DISTINCT "status" FROM "orders" ORDER BY "status"
Recap¶
.aggregate(groupBy, spec)→ row = group columns + aggregate aliases.count→number;sum/avg/min/max→number | null.wherefilters beforeGROUP BY;.having()filters after, by alias;[]as groupBy aggregates the whole table..orderBy(alias)orders by the aggregate result..distinct()emitsSELECT DISTINCT.