Escape
HomeDocsTotals

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.

report and group aggregates
{
  "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.

composing two aggregates
{ "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

an honest empty state
{ "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.

inline reducers
{ "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.

a running balance
{
  "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.

Fold the rows. Say so when there are none.

npm install quario
Getting started
© 2026 quario · KvK 61815977