Skip to content

Commit b76ee99

Browse files
committed
feat(db): support custom aggregate functions
Add a global, case-insensitive registry for user-defined aggregates. The group-by compiler now looks up registrations before the built-in switch, so custom names work anywhere built-ins do — select, having, and orderBy via $selected — and built-ins can be overridden (warned about in dev) and restored by unregistering. Public API: - createAggregate(name, factory): registers and returns a typed builder, so the aggregate name is declared once and its result type flows into select() - registerAggregate / unregisterAggregate / getRegisteredAggregates for dynamic registration - toExpression, ExpressionLike and the Aggregate type are now exported for the low-level path Factories receive the raw value extractor plus the row key, letting aggregates such as group_concat stay deterministic despite preMap outputs being consolidated by value hash. Arguments after the first are evaluated once at compile time and must be constant, otherwise NonConstantAggregateArgumentError is thrown; UnsupportedAggregateFunctionError now lists registered names. Closes #1558
1 parent 67c840f commit b76ee99

9 files changed

Lines changed: 998 additions & 4 deletions

File tree

.changeset/smart-pugs-listen.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/db': minor
3+
---
4+
5+
Add support for custom aggregate functions. `createAggregate(name, factory)` registers an aggregate and returns a typed helper for use in `select()`, and the lower-level `registerAggregate` / `unregisterAggregate` / `getRegisteredAggregates` APIs are available for dynamic registration. Custom aggregates work anywhere built-ins do, including `having` and `orderBy` via `$selected`.

docs/guides/live-queries.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1495,6 +1495,74 @@ const orderStats = createCollection(liveQueryCollectionOptions({
14951495

14961496
See the [Aggregate Functions](#aggregate-functions) section for a complete list of available aggregate functions.
14971497

1498+
### Custom Aggregate Functions
1499+
1500+
If the built-in aggregates aren't enough, register your own with `createAggregate`. It registers the aggregate and returns a typed helper you can call in `select`:
1501+
1502+
```ts
1503+
import { createAggregate, createCollection, liveQueryCollectionOptions } from '@tanstack/db'
1504+
1505+
// Concatenates the values of a group, ordered by row key
1506+
const groupConcat = createAggregate<string, [separator?: string]>(
1507+
'group_concat',
1508+
(ctx, [separator = ',']) => ({
1509+
// Pair each value with its row key so rows stay distinct
1510+
preMap: (entry) => [ctx.key(entry), String(ctx.value(entry) ?? '')],
1511+
reduce: (values) => {
1512+
const rows: Array<[string, string]> = []
1513+
for (const [row, multiplicity] of values) {
1514+
for (let i = 0; i < multiplicity; i++) rows.push(row)
1515+
}
1516+
rows.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
1517+
return rows.map(([, text]) => text).join(separator)
1518+
},
1519+
})
1520+
)
1521+
1522+
const listSummaries = createCollection(liveQueryCollectionOptions({
1523+
query: (q) =>
1524+
q
1525+
.from({ todo: todosCollection })
1526+
.groupBy(({ todo }) => todo.listId)
1527+
.select(({ todo }) => ({
1528+
listId: todo.listId,
1529+
allNames: groupConcat(todo.text, ' | '), // typed as string
1530+
}))
1531+
}))
1532+
```
1533+
1534+
An implementation has three parts:
1535+
1536+
- `preMap(entry)` — maps one row to the value that gets aggregated. Use `ctx.value(entry)` for the raw value of the first argument (no numeric coercion) and `ctx.key(entry)` for the row's key.
1537+
- `reduce(values)` — receives the **entire group** on every change as `[value, multiplicity]` pairs and returns the reduced value. It is a full recompute, not a delta, so no accumulator bookkeeping is needed.
1538+
- `postMap(result)` — optional final transformation of the reduced value.
1539+
1540+
Arguments after the first one become the `params` tuple passed to your factory. They are evaluated once at query-compile time and must be constants — referencing a column throws `NonConstantAggregateArgumentError`.
1541+
1542+
> [!IMPORTANT]
1543+
> Values returned by `preMap` are consolidated by value: two rows producing the same value become a single entry with a multiplicity of `2`, and iteration order is not row order. Ignoring `multiplicity` silently drops duplicates. When order or per-row identity matters, include `ctx.key(entry)` in the `preMap` output (as above) and sort in `reduce`.
1544+
1545+
For dynamic scenarios there is also a lower-level API:
1546+
1547+
```ts
1548+
import { registerAggregate, unregisterAggregate, getRegisteredAggregates, IR, toExpression } from '@tanstack/db'
1549+
1550+
registerAggregate('bit_or', (ctx) => ({
1551+
preMap: (entry) => Number(ctx.value(entry)) | 0,
1552+
reduce: (values) => values.reduce((acc, [value]) => acc | value, 0),
1553+
}))
1554+
1555+
// Build the IR node yourself
1556+
const bitOr = (arg) => new IR.Aggregate('bit_or', [toExpression(arg)])
1557+
1558+
getRegisteredAggregates() // ReadonlySet<string> of registered names
1559+
unregisterAggregate('bit_or') // true if a registration existed
1560+
```
1561+
1562+
Registration is global and case-insensitive. Registering a name that already exists — including a built-in like `sum` — replaces it for queries compiled *afterwards* and logs a warning in development. Queries already compiled keep the implementation they were compiled with, so overriding built-ins can produce inconsistent results across your app; prefer a distinct name. Unregistering a name that shadowed a built-in restores the built-in.
1563+
1564+
Custom aggregates work anywhere built-ins do, including `having` and ordering by `$selected.<alias>`. Because factories are plain functions, they cannot be serialized: with SSR, make sure the same registrations run on both the server and the client.
1565+
14981566
### Having Clauses
14991567

15001568
Filter aggregated results using `having` - this is similar to the `where` clause, but is applied after the aggregation has been performed.

packages/db/src/errors.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -631,8 +631,26 @@ export class NonAggregateExpressionNotInGroupByError extends GroupByError {
631631
}
632632

633633
export class UnsupportedAggregateFunctionError extends GroupByError {
634-
constructor(functionName: string) {
635-
super(`Unsupported aggregate function: ${functionName}`)
634+
constructor(functionName: string, registeredNames?: Iterable<string>) {
635+
const registered = registeredNames ? [...registeredNames] : []
636+
super(
637+
`Unsupported aggregate function: ${functionName}` +
638+
(registered.length > 0
639+
? `. Registered custom aggregates: ${registered.join(`, `)}`
640+
: ``),
641+
)
642+
}
643+
}
644+
645+
/**
646+
* Error thrown when an argument after the first of an aggregate expression
647+
* is not a constant (e.g. it references a column).
648+
*/
649+
export class NonConstantAggregateArgumentError extends GroupByError {
650+
constructor(functionName: string, argIndex: number) {
651+
super(
652+
`Argument ${argIndex} of aggregate function '${functionName}' must be a constant expression, not a column reference`,
653+
)
636654
}
637655
}
638656

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { Aggregate } from './ir.js'
2+
import { toExpression } from './builder/ref-proxy.js'
3+
import type { ExpressionLike } from './builder/functions.js'
4+
import type { NamespacedRow } from '../types.js'
5+
6+
/**
7+
* A single row as seen by an aggregate: `[rowKey, namespacedRow]`.
8+
*/
9+
export type AggregateEntry = [string, NamespacedRow]
10+
11+
/**
12+
* Accessors handed to a custom aggregate factory.
13+
*/
14+
export type AggregateContext = {
15+
/**
16+
* Raw value of the aggregate's first argument for this row.
17+
* No numeric coercion is applied.
18+
*/
19+
value: (entry: AggregateEntry) => unknown
20+
/**
21+
* Stable per-row key. Use it to keep rows distinct (values emitted by
22+
* `preMap` are consolidated by hash) or to order deterministically.
23+
*/
24+
key: (entry: AggregateEntry) => string
25+
}
26+
27+
/**
28+
* Implementation of a custom aggregate, mirroring db-ivm's basic aggregate contract.
29+
*
30+
* `reduce` receives the complete consolidated multiset for the group on every
31+
* change, as `[value, multiplicity]` pairs — it is a full recompute, not a delta.
32+
* Ignoring `multiplicity` under-counts duplicate values.
33+
*/
34+
export type CustomAggregateImpl<TValue = unknown, TResult = unknown> = {
35+
preMap: (entry: AggregateEntry) => TValue
36+
reduce: (values: Array<[TValue, number]>) => TValue
37+
postMap?: (result: TValue) => TResult
38+
}
39+
40+
/**
41+
* Factory that builds a custom aggregate implementation for one compiled query.
42+
*
43+
* `additionalArgs` holds the evaluated values of any arguments after the first
44+
* one in the aggregate expression; they must be constant expressions.
45+
*/
46+
export type CustomAggregateFactory<TValue = any, TResult = unknown> = (
47+
ctx: AggregateContext,
48+
additionalArgs: Array<unknown>,
49+
) => CustomAggregateImpl<TValue, TResult>
50+
51+
// `any` for the value type: it is existential from the registry's point of view,
52+
// and `unknown` would make user implementations non-assignable (contravariance).
53+
type AnyCustomAggregateFactory = CustomAggregateFactory<any, unknown>
54+
55+
/** Aggregate names implemented natively by the group-by compiler. */
56+
export const BUILTIN_AGGREGATE_NAMES: ReadonlySet<string> = new Set([
57+
`sum`,
58+
`count`,
59+
`avg`,
60+
`min`,
61+
`max`,
62+
])
63+
64+
const customAggregates = new Map<string, AnyCustomAggregateFactory>()
65+
66+
const DEV =
67+
typeof process !== `undefined` && process.env.NODE_ENV !== `production`
68+
69+
/**
70+
* Registers a custom aggregate function under `name` (case-insensitive).
71+
*
72+
* Re-registering a name — including a built-in — replaces the previous
73+
* implementation for queries compiled afterwards and warns in development.
74+
* Already-compiled live queries keep the implementation they were compiled with.
75+
*/
76+
export function registerAggregate(
77+
name: string,
78+
factory: AnyCustomAggregateFactory,
79+
): void {
80+
const normalized = name.toLowerCase()
81+
82+
if (DEV) {
83+
if (BUILTIN_AGGREGATE_NAMES.has(normalized)) {
84+
console.warn(
85+
`[@tanstack/db] registerAggregate("${name}") overrides the built-in ` +
86+
`aggregate "${normalized}". This affects every query compiled afterwards, ` +
87+
`app-wide. Already-compiled queries keep the built-in behavior.`,
88+
)
89+
} else if (customAggregates.has(normalized)) {
90+
console.warn(
91+
`[@tanstack/db] registerAggregate("${name}") replaces an existing custom ` +
92+
`aggregate registration. Queries compiled before this call keep the ` +
93+
`previous implementation.`,
94+
)
95+
}
96+
}
97+
98+
customAggregates.set(normalized, factory)
99+
}
100+
101+
/**
102+
* Removes a custom aggregate registration.
103+
*
104+
* If the name shadowed a built-in, the built-in becomes active again because
105+
* the compiler falls back to it when no registration exists.
106+
*
107+
* @returns whether a registration existed for the name
108+
*/
109+
export function unregisterAggregate(name: string): boolean {
110+
return customAggregates.delete(name.toLowerCase())
111+
}
112+
113+
/** Names of all currently registered custom aggregates. */
114+
export function getRegisteredAggregates(): ReadonlySet<string> {
115+
return new Set(customAggregates.keys())
116+
}
117+
118+
/** Looks up a registered custom aggregate factory. Used by the compiler. */
119+
export function getCustomAggregate(
120+
name: string,
121+
): AnyCustomAggregateFactory | undefined {
122+
return customAggregates.get(name.toLowerCase())
123+
}
124+
125+
/**
126+
* Registers a custom aggregate and returns a typed builder function for use in
127+
* `select()` callbacks.
128+
*
129+
* @param name - Aggregate name (case-insensitive)
130+
* @param factory - Builds the aggregate implementation from the row accessors
131+
* and the evaluated extra parameters
132+
* @returns a function taking the aggregated expression plus the extra parameters
133+
*
134+
* @example
135+
* ```ts
136+
* const groupConcat = createAggregate<string, [separator?: string]>(
137+
* `group_concat`,
138+
* (ctx, [separator = `,`]) => ({
139+
* preMap: (entry) => [ctx.key(entry), String(ctx.value(entry) ?? ``)],
140+
* reduce: (values) =>
141+
* values
142+
* .filter(([, multiplicity]) => multiplicity > 0)
143+
* .sort(([a], [b]) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
144+
* .map(([[, text]]) => text)
145+
* .join(separator),
146+
* }),
147+
* )
148+
*
149+
* query.groupBy(({ todo }) => todo.listId).select(({ todo }) => ({
150+
* listId: todo.listId,
151+
* names: groupConcat(todo.text, ` | `),
152+
* }))
153+
* ```
154+
*/
155+
export function createAggregate<TResult, TParams extends Array<unknown> = []>(
156+
name: string,
157+
factory: (
158+
ctx: AggregateContext,
159+
params: TParams,
160+
) => CustomAggregateImpl<any, TResult>,
161+
): (arg: ExpressionLike, ...params: TParams) => Aggregate<TResult> {
162+
registerAggregate(name, (ctx, additionalArgs) =>
163+
factory(ctx, additionalArgs as TParams),
164+
)
165+
166+
return (arg: ExpressionLike, ...params: TParams) =>
167+
new Aggregate<TResult>(name, [
168+
toExpression(arg),
169+
...params.map((param) => toExpression(param)),
170+
])
171+
}

packages/db/src/query/builder/functions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ type ComparisonOperandPrimitive<T extends string | number | boolean> =
4545
| null
4646

4747
// Helper type for values that can be lowered to expressions.
48-
type ExpressionLike =
48+
export type ExpressionLike =
4949
| Aggregate
5050
| BasicExpression
5151
| RefProxy<any>

packages/db/src/query/compiler/group-by.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ import {
1515
import {
1616
AggregateFunctionNotInSelectError,
1717
NonAggregateExpressionNotInGroupByError,
18+
NonConstantAggregateArgumentError,
1819
UnknownHavingExpressionTypeError,
1920
UnsupportedAggregateFunctionError,
2021
} from '../../errors.js'
22+
import { getCustomAggregate, getRegisteredAggregates } from '../aggregates.js'
2123
import {
2224
compileExpression,
2325
isCaseWhenConditionTrue,
@@ -551,6 +553,15 @@ function getAggregateFunction(aggExpr: Aggregate) {
551553
return compiledExpr(namespacedRow)
552554
}
553555

556+
// Custom registrations take precedence so that built-ins can be overridden
557+
const custom = getCustomAggregate(aggExpr.name)
558+
if (custom) {
559+
return custom(
560+
{ value: rawValueExtractor, key: ([key]) => key },
561+
compileAdditionalAggregateArgs(aggExpr),
562+
)
563+
}
564+
554565
// Return the appropriate aggregate function
555566
switch (aggExpr.name.toLowerCase()) {
556567
case `sum`:
@@ -564,8 +575,38 @@ function getAggregateFunction(aggExpr: Aggregate) {
564575
case `max`:
565576
return max(valueExtractorForMinMax)
566577
default:
567-
throw new UnsupportedAggregateFunctionError(aggExpr.name)
578+
throw new UnsupportedAggregateFunctionError(
579+
aggExpr.name,
580+
getRegisteredAggregates(),
581+
)
582+
}
583+
}
584+
585+
/**
586+
* Evaluates the arguments after the first one of an aggregate expression into
587+
* static values passed to a custom aggregate factory. They must be constant,
588+
* since they are evaluated once at compile time against an empty row.
589+
*/
590+
function compileAdditionalAggregateArgs(aggExpr: Aggregate): Array<unknown> {
591+
return aggExpr.args.slice(1).map((arg, index) => {
592+
if (containsRef(arg)) {
593+
throw new NonConstantAggregateArgumentError(aggExpr.name, index + 1)
594+
}
595+
return compileExpression(arg)({})
596+
})
597+
}
598+
599+
/**
600+
* Whether an expression references a column, making it non-constant.
601+
*/
602+
function containsRef(expr: BasicExpression): boolean {
603+
if (expr.type === `ref`) {
604+
return true
568605
}
606+
if (expr.type === `func`) {
607+
return expr.args.some((arg) => containsRef(arg))
608+
}
609+
return false
569610
}
570611

571612
/**

packages/db/src/query/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,24 @@ export {
7575

7676
// Ref proxy utilities
7777
export type { Ref } from './builder/types.js'
78+
export { toExpression } from './builder/ref-proxy.js'
79+
export type { ExpressionLike } from './builder/functions.js'
80+
81+
// Custom aggregate functions
82+
export type { Aggregate } from './ir.js'
83+
export {
84+
registerAggregate,
85+
unregisterAggregate,
86+
getRegisteredAggregates,
87+
createAggregate,
88+
BUILTIN_AGGREGATE_NAMES,
89+
} from './aggregates.js'
90+
export type {
91+
AggregateContext,
92+
AggregateEntry,
93+
CustomAggregateImpl,
94+
CustomAggregateFactory,
95+
} from './aggregates.js'
7896

7997
// Compiler
8098
export { compileQuery } from './compiler/index.js'

0 commit comments

Comments
 (0)