* [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>
6.4 KiB
description, tags
| description | tags |
|---|---|
| APIs to define custom aggregate functions for LIQ | 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():
-- 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 asname(expr))initialize: function that returns the initial stateiterate: function(state, value) that returns updated state
Optional keys:
description: description of the aggregatefinish: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 tableiterate(state, value, ctx, ...extraArgs)— receives extra args after the context tablefinish(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).
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 ", "):
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:
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:
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:
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
aggregate.update {
name = 'count',
description = 'Custom count aggregate that counts even nils',
iterate = function(state, value)
return state + 1
end,
}
Create an alias
aggregate.alias("total", "sum")
aggregate.alias("stdev", "stddev_pop", "Shorthand for population stddev")
Implementation
-- 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')