Files
plainleaf/libraries/Library/Std/APIs/Aggregate.md
T
Matouš Jan FialkaandGitHub 6cbb61c7db [LIQ] Add new aggregate functions, aliases, and queryable aggregate registry (#1891)
* [LIQ] Add new aggregate functions, aliases, and queryable aggregate registry

* Extend with 13 new built-in aggregates: `product`, `string_agg`,
  `yaml_agg`, `json_agg`, `bit_and`, `bit_or`, `bit_xor`, `bool_and`,
  `bool_or`, `stddev_pop`, `stddev_samp`, `var_pop` and `var_samp`.

* Introduce `aggregate.alias` API allowing users to define custom
  aliases for any aggregate. Standard aliases (`every`, `std`, `stddev`
  and `variance`) are now defined via this API rather than hardcoded.

* Add `index.aggregates` queryable collection so users can discover
  all available aggregates directly from LIQ queries.

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Fix config pass through query path so custom aggregates work

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Fix: Preserve `LuaTable`/`LuaFunction` values in aggregate config storage

`config.set` uses `LuaNativeJSFunction` which calls `luaValueToJS` on
all arguments. This converted the aggregate `LuaTable` to a plain JS
object and wrapped `LuaFunction` callbacks in JS functions that also
converted their returned values via `luaValueToJS`. The result was that
state returned by initialize (a `LuaTable`) got converted to a plain JS
object before being passed to `iterate`. Therefor Lua operations like
`table.insert` on that were failing because they expected a `LuaTable`
and not a plain JS array.

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Fix formatting

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Improve aggregate functions descriptions, fix `sum` divergence

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Align `product` with `sum`

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Fix: extract `alias` from `LuaTable` via `rawGet` in `aggregates()` registry

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Rename `alias` in `aggregates()` to `target` for clarity

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Fix: Add a null guard at the top of `jsToLuaValue`

This preserves `null`/`undefined` as-is (both map to Lua nil) and
prevents them from falling through to the `typeof` "object" branch.

For this PR it means that null `target` in our `aggregates` entries will
correctly show as empty/`nil` in query results rather than `{}`.

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Fix: Documentation reflects recent changes

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Fix: make `sum`/`product` return null on empty input; stop `LIQ_NULL` leaks

* `sum(`) and` product(`) now return null when no rows match (matching
  Postgres semantics) instead of returning 0 and 1 respectively.

* Query result columns that hold null are internally preserved using
  a `LIQ_NULL` sentinel so that column keys survive in `LuaTable`
  storage.  This sentinel was leaking into Lua code as "userdata"
  through three read paths:

  * `luaIndexValue`: `rawGet` returned the sentinel directly to Lua when
    accessing table fields,

  * `rawget` (stdlib): the builtin `rawget` function exposed the
    sentinel without converting it back to `nil`,

  * `createAugmentedEnv`: string interpolation unpacked table values via
    `rawGet` into local variables, making the sentinel visible in
    template expressions like `${var}`.

  All three now convert `LIQ_NULL` to `nil` at the read boundary,
  keeping the sentinel internal to table storage where it belongs.

* Update affected test expectations accordingly.

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Fix: Remove duplicated LIQ_NULL hazard, add guard for all builtin aggregate `iterate`s

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Fix: `array_agg` preserves NULL positions

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Fix: Add symbol guard to `json_agg`

`JSON.stringify(Symbol(...))` in an array produces null by accident.
That is a JS implementation detail we **MUST NOT** rely on. Explicit
null push makes intent clear and avoids surprises if the `Symbol`
representation ever changes.

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Fix: Add symbol guard to `yaml_agg` (ditto)

`js-yaml` has no knowledge of the `LIQ_NULL` symbol. Passing null makes
it emit YAML null (or `~`), which is the correct YAML representation of
a missing value and matches standard `json_agg`/`yaml_agg`
NULL-inclusion semantics.

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Fix: Add intra-aggregate ordering null guards

Without this, `LIQ_NULL` sort keys would fall through to `valA < valB`
which is always false for `Symbol`s which is breaking the `nulls
first`/`nulls last` contract...

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Fix: Ditto, but for `order by` null comparisons

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Fix: Guard `luaTypeName`, `luaTypeOf` and `luaToString` against `LIQ_NULL` sentinel

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Fix: Guard presentation layer against `LIQ_NULL` sentinel leaking as visible text

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Fix: Evaluate extra args per-item in `executeAggregate`; add new aggregates

Extra arguments (2nd, 3rd, etc.) to aggregate functions were evaluated
against the outer query environment where the object variable is not
bound. This caused multi-argument aggregates like `covar_samp(data.y,
data.x)` to fail with nil reference errors. This commit addresses this
by evaluating extra args per-item inside the iterate loop using the item
environment so all arguments resolve correctly.

We also add few common aggregates:

- `covar_pop`, `covar_samp`, `corr`: population/sample covariance and
  correlation coefficient using online co-moment algorithm.

- `quantile(value, q, method)`: general quantile with interpolation
  methods: lower, higher, nearest, midpoint and default linear.

- `percentile_cont(value, q)`: continuous percentile (linear)

- `percentile_disc(value, q)`: discrete percentile (lower)

Note: `percentile_cont` and `percentile_disc` share the `quantile`
implementation through `ctx.name` at initialize time.

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Update docs

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Make the ordering for quantile aggregates explicit

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Update docs

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

* Improve docs

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>

---------

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
2026-03-19 09:37:09 +01:00

256 lines
6.4 KiB
Markdown

---
description: APIs to define custom aggregate functions for LIQ
tags: meta/api
---
APIs to define and override aggregate functions used in LIQ `select` and `having` clauses after `group by`.
All aggregates skip null/nil values by convention. Empty groups return null (except `count` which returns 0 and `string_agg` which returns an empty string).
# Querying available aggregates
All aggregates (built-in, user-defined, and aliases) are queryable via `index.aggregates()`:
```lua
-- List all
${query[[from index.aggregates()]]}
-- Only builtins
${query[[from index.aggregates() where builtin]]}
-- Only aliases
${query[[from index.aggregates() where target]]}
-- Only user-defined (non-builtin, non-alias)
${query[[from index.aggregates() where not builtin and not target]]}
```
Each row includes the following columns: `builtin`, `name`, `description`, `initialize`, `iterate`, `finish`, and `target`. The `initialize`, `iterate`, and `finish` columns are represented by boolean values.
# API
## aggregate.define(spec)
Defines a new aggregate function. Required keys:
* `name`: name of the aggregate (used in queries as `name(expr)`)
* `initialize`: function that returns the initial state
* `iterate`: function(state, value) that returns updated state
Optional keys:
* `description`: description of the aggregate
* `finish`: `function(state)` that transforms the final state into the result
### Extra arguments
Aggregate functions can accept additional arguments beyond the first value expression. When called as `my_agg(expr, arg2, arg3)`, the extra arguments (`arg2`, `arg3`) are evaluated once before iteration and forwarded to all three callbacks:
* `initialize(ctx, ...extraArgs)` — receives extra args after the context table
* `iterate(state, value, ctx, ...extraArgs)` — receives extra args after the context table
* `finish(state, ctx, ...extraArgs)` — receives extra args after the context table
This allows parameterized aggregates, for example a separator argument for string concatenation or boundary arguments for clamped sums.
## aggregate.update(spec)
Updates an existing aggregate definition. Same keys as `aggregate.define`. Only the provided keys are overwritten.
## aggregate.alias(name, target, description?)
Creates an alias so that `name` resolves to `target` at query time. The target may be a builtin, a user-defined aggregate, or another alias (chains are followed with cycle detection).
```lua
aggregate.alias("total", "sum")
aggregate.alias("stdev", "stddev_pop", "My stddev alias")
```
# Examples
## Define a custom aggregate with one extra argument
Define a custom aggregate `concat` that concatenates strings with a configurable separator (defaulting to `", "`):
```lua
aggregate.define {
name = 'concat',
initialize = function(ctx, sep)
return { sep = sep or ', ', parts = {} }
end,
iterate = function(state, value)
if value ~= nil then
state.parts[#state.parts + 1] = tostring(value)
end
return state
end,
finish = function(state)
return table.concat(state.parts, state.sep)
end,
}
```
Usage in a query:
```lua
query [[
from p = data
group by p.category
select {
cat = key,
names = concat(p.name),
names_dash = concat(p.name, " - ")
}
]]
```
## Define a custom aggregate with two extra arguments
Define a custom aggregate `clamp_sum` that sums non-null inputs and clamps the result to a `[min, max]` range:
```lua
aggregate.define {
name = 'clamp_sum',
description = 'Sum of non-null inputs clamped to [min, max]',
initialize = function(ctx, lo, hi)
return { total = 0, lo = lo or -math.huge, hi = hi or math.huge }
end,
iterate = function(state, value)
if value ~= nil then
state.total = state.total + value
end
return state
end,
finish = function(state)
if state.total < state.lo then return state.lo end
if state.total > state.hi then return state.hi end
return state.total
end,
}
```
Usage in a query:
```lua
query [[
from
d = {
{ dept = "eng", hours = 12 },
{ dept = "eng", hours = 35 },
{ dept = "sales", hours = 8 },
{ dept = "sales", hours = 6 },
}
group by d.dept
select {
dept = d.dept,
total = clamp_sum(d.hours, 0, 40),
}
]]
```
Here `eng` sums to 47 but is clamped to `40`, while `sales` sums to `14` which is within range.
## Update an existing aggregate
```lua
aggregate.update {
name = 'count',
description = 'Custom count aggregate that counts even nils',
iterate = function(state, value)
return state + 1
end,
}
```
## Create an alias
```lua
aggregate.alias("total", "sum")
aggregate.alias("stdev", "stddev_pop", "Shorthand for population stddev")
```
# Implementation
```space-lua
-- priority: 50
aggregate = aggregate or {}
local aggregateSchema = {
type = 'object',
required = {
'name',
'initialize',
'iterate'
},
properties = {
name = schema.string(),
description = schema.string(),
initialize = schema.func(),
iterate = schema.func(),
finish = schema.func(),
}
}
function aggregate.define(spec)
local validationResult = jsonschema.validateObject(aggregateSchema, spec)
if validationResult then
error('aggregate.define: ' .. validationResult)
end
config.setLuaValue({'aggregates', spec.name}, spec)
end
function aggregate.update(spec)
if not spec.name then
error('aggregate.update: name is required')
end
local existing = config.get({'aggregates', spec.name}, {})
for k, v in pairs(spec) do
existing[k] = v
end
if not existing.initialize then
error('aggregate.update: aggregate '
.. spec.name .. ' has no initialize after merge')
end
if not existing.iterate then
error('aggregate.update: aggregate '
.. spec.name .. ' has no iterate after merge')
end
config.setLuaValue({'aggregates', spec.name}, existing)
end
function aggregate.alias(name, target, description)
if not name or not target then
error('aggregate.alias: both name and target are required')
end
if name == target then
error('aggregate.alias: name and target must differ')
end
local entry = { alias = target }
if description then
entry.description = description
end
config.setLuaValue({'aggregates', name}, entry)
end
-- Standard aliases
aggregate.alias('every', 'bool_and')
aggregate.alias('std', 'stddev_pop')
aggregate.alias('stddev', 'stddev_pop')
aggregate.alias('variance', 'var_pop')
```