Totals
Intent
Three different things get called a total. An aggregate folds the rows in scope into one value. An inline reducer folds an array a single row carries. A running value holds a different answer on every row, according to the rows before it. Choosing the wrong one is the most common way a report comes out plausible and wrong.
Design decisions
An aggregate is a declaration, and an inline reducer is a call. The same
six names serve both, which is deliberate, but they sit in different places. A
declaration is "subtotal": "sum:[email protected] * @.unitPrice" under aggregates. A
call is sum(@.lines, l => l.total) inside an expression. Writing
"total": "round:[email protected]" is a definition error, because round folds nothing.
Numeric coercion is quiet, and that’s the point. sum and avg coerce
each per-row value, and anything that doesn’t come out a finite number
contributes 0. A report keeps rendering when a data feed degrades rather than
throwing over one bad cell. Where absence has to be visible, test for it in the
report or validate the feed before it arrives.
An empty scope gives 0 for the counting reducers and null for the rest.
sum, count and countDistinct return 0, which is a measured result worth
preserving. avg, min and max return null, because there is no honest
number to give.
min and max compare without coercing. Mixed types therefore compare by
JavaScript ordering. Keep such a column one type where the extremes matter.
countDistinct counts what a by key would partition into. It folds on
the same key identity a group’s by uses, so countDistinct:[email protected] is the
number of instances by: "[email protected]" would open over those rows. Two
equal-looking objects are two distinct values for the same reason they would be
two instances.
There is no SQL-style null skipping. null is an ordinary value here, and
since every absent read is null, a missing field and an explicit null share
one bucket. COUNT(DISTINCT x) in SQL skips nulls, and countDistinct counts
them as one of the distinct values.
count’s lambda is a predicate. Every other reducer’s is a projection.
count(@.lines, l => l.active) counts the active lines.
countDistinct(@.lines, l => l.active) counts how many distinct answers that
test gave, which is at most three.
A reducer folds an array and says so when handed anything else. The quiet
coercion covers the values folded, never the collection they came from. A feed
delivering lines as a string fails loudly. An absent array isn’t a mistake,
so sum(@.lines) on a row with no lines folds the empty array and gives 0.
Running values reset by scope. A run block on the report persists across
the document. A run block on a group starts again at each instance. Where a
runner resets is the only thing that decides what it means.
API walkthrough
Declaring an aggregate
Declare under aggregates on the report or on a group. The reducer runs over
the rows in scope, evaluating the expression per row with @ bound to that
row.
{
"aggregates": { "grandTotal": "sum:[email protected] * @.unitPrice" },
"groups": [
{
"name": "region",
"by": "[email protected]",
"aggregates": { "subtotal": "sum:[email protected] * @.unitPrice", "orders": "count" }
}
]
}The six reducers are sum, count, countDistinct, avg, min and max.
count takes no expression, and every other reducer takes the form
name:=expression.
You read a report aggregate as $.<name> and a group aggregate as
<groupName>.<name>. The engine computes both once and injects them as scope
values, so they compose in later expressions like any other variable.
{ "value": "{{ round(region.subtotal / $.grandTotal * 100, 1) }}%" }Names stay reserved where they would collide. A report aggregate can’t
take the name input or params, and a group aggregate can’t take
key.
Groups have no zero-row instance. Empty input opens no handle and renders no
group band at all, which is why the report’s empty band presents report
aggregates through $.
Presenting an aggregate that has nothing to say
{ "type": "text", "value": "{{ $.rows }} rows, average {{ $.average == null ? '—' : $.average }}",
"style": { "size": 10 } }Test with == null. Don’t reach for ||, which also replaces a valid 0 and
turns a measured zero into a dash.
Folding a row’s own array
The same six names are functions in the default registry, each taking an array and an optional per-item lambda. Use them where a row carries its own lines and you don’t want a group.
{ "value": "{{ sum(@.lines, l => l.qty * l.price) }}", "style": { "format": "currency" } }Lambdas take a single bare parameter and no parentheses. The reducer owns the iteration, so the lambda stays a pure per-item read.
A registered function of the same name overrides an inline reducer. Declared aggregates always use the built-in ones.
Accumulating down the rows
Declare under run, and read as run.<name> on a detail row. The reducers are
the six above plus prev, which holds the previous row’s value and is null
on the first.
{
"run": { "balance": "sum:[email protected] - @.credit" },
"detail": {
"columns": [
{ "header": { "value": "Balance" }, "value": "{{ round(run.balance, 2) }}",
"style": { "align": "right", "format": "currency" } }
]
}
}Running values follow render order, which is the order rows appear after grouping and sorting. They’re computed during the banded walk rather than before it, and they update for every selected row before visibility decides whether to emit it.
Run names can’t repeat across the report and its nested groups, because every
run value shares one run object. When no run block is in scope at all,
run itself is unbound and run.<name> throws.
Placing a total where it belongs
A detail.total block draws at the foot of the table, which under a group band
means once per group. Totals for the whole document belong in the report
footer, which draws once. Bands covers the
distinction.
A total cell carries span to reach across columns. A total row then reads as
a label and an amount rather than a line of empty spacers.
Settling the arithmetic
Wrap arithmetic a cell does for itself in round. The HTML and PDF targets
present through format, so round never changes what they show. The XLSX
and CSV targets keep the number the engine hands them, so an unrounded sum
shows there in full. Formatting covers the
gap between counting displayed places and counting stored ones.