Skip to content

Commit 98b251a

Browse files
committed
Public api for custom operators
1 parent 0d37df5 commit 98b251a

37 files changed

+2897
-1090
lines changed

.changeset/auto-register-operators.md

Lines changed: 48 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -8,61 +8,70 @@ Each operator and aggregate now bundles its builder function and evaluator facto
88

99
- **True tree-shaking**: Only operators/aggregates you import are included in your bundle
1010
- **No global registry**: No side-effect imports needed; each node is self-contained
11-
- **Custom operators**: Create custom operators by building `Func` nodes with a factory
12-
- **Custom aggregates**: Create custom aggregates by building `Aggregate` nodes with a config
11+
- **Custom operators**: Use `defineOperator()` to create custom operators
12+
- **Custom aggregates**: Use `defineAggregate()` to create custom aggregates
13+
- **Factory helpers**: Use `comparison()`, `transform()`, `numeric()`, `booleanOp()`, and `pattern()` to easily create operator evaluators
1314

1415
**Custom Operator Example:**
1516

1617
```typescript
17-
import {
18-
Func,
19-
type EvaluatorFactory,
20-
type CompiledExpression,
21-
} from '@tanstack/db'
22-
import { toExpression } from '@tanstack/db/query'
18+
import { defineOperator, isUnknown } from '@tanstack/db'
2319

24-
const betweenFactory: EvaluatorFactory = (compiledArgs, _isSingleRow) => {
25-
const [valueEval, minEval, maxEval] = compiledArgs
26-
return (data) => {
27-
const value = valueEval!(data)
28-
return value >= minEval!(data) && value <= maxEval!(data)
20+
// Define a custom "between" operator
21+
const between = defineOperator<boolean>({
22+
name: 'between',
23+
evaluate: ([valueArg, minArg, maxArg]) => (data) => {
24+
const value = valueArg!(data)
25+
if (isUnknown(value)) return null
26+
return value >= minArg!(data) && value <= maxArg!(data)
2927
}
30-
}
28+
})
3129

32-
function between(value: any, min: any, max: any) {
33-
return new Func(
34-
'between',
35-
[toExpression(value), toExpression(min), toExpression(max)],
36-
betweenFactory,
37-
)
38-
}
30+
// Use in a query
31+
query.where(({ user }) => between(user.age, 18, 65))
32+
```
33+
34+
**Using Factory Helpers:**
35+
36+
```typescript
37+
import { defineOperator, comparison, transform, numeric } from '@tanstack/db'
38+
39+
// Binary comparison with automatic null handling
40+
const notEquals = defineOperator<boolean>({
41+
name: 'notEquals',
42+
evaluate: comparison((a, b) => a !== b)
43+
})
44+
45+
// Unary transformation
46+
const double = defineOperator<number>({
47+
name: 'double',
48+
evaluate: transform((v) => typeof v === 'number' ? v * 2 : v)
49+
})
50+
51+
// Binary numeric operation
52+
const modulo = defineOperator<number>({
53+
name: 'modulo',
54+
evaluate: numeric((a, b) => b !== 0 ? a % b : null)
55+
})
3956
```
4057

4158
**Custom Aggregate Example:**
4259

4360
```typescript
44-
import {
45-
Aggregate,
46-
type AggregateConfig,
47-
type ValueExtractor,
48-
} from '@tanstack/db'
49-
import { toExpression } from '@tanstack/db/query'
61+
import { defineAggregate } from '@tanstack/db'
5062

51-
const productConfig: AggregateConfig = {
52-
factory: (valueExtractor: ValueExtractor) => ({
63+
const product = defineAggregate<number>({
64+
name: 'product',
65+
factory: (valueExtractor) => ({
5366
preMap: valueExtractor,
5467
reduce: (values) => {
55-
let product = 1
68+
let result = 1
5669
for (const [value, multiplicity] of values) {
57-
for (let i = 0; i < multiplicity; i++) product *= value
70+
for (let i = 0; i < multiplicity; i++) result *= value
5871
}
59-
return product
60-
},
72+
return result
73+
}
6174
}),
62-
valueTransform: 'numeric',
63-
}
64-
65-
function product<T>(arg: T): Aggregate<number> {
66-
return new Aggregate('product', [toExpression(arg)], productConfig)
67-
}
75+
valueTransform: 'numeric'
76+
})
6877
```

docs/guides/live-queries.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1866,6 +1866,95 @@ concat(
18661866
avg(add(user.salary, coalesce(user.bonus, 0)))
18671867
```
18681868

1869+
### Custom Operators
1870+
1871+
You can define your own operators using the `defineOperator` function. This is useful when you need specialized comparison logic or domain-specific operations.
1872+
1873+
```ts
1874+
import { defineOperator, isUnknown, comparison } from '@tanstack/db'
1875+
1876+
// Define a custom "between" operator
1877+
const between = defineOperator<boolean>({
1878+
name: 'between',
1879+
evaluate: ([valueArg, minArg, maxArg]) => (data) => {
1880+
const value = valueArg!(data)
1881+
const min = minArg!(data)
1882+
const max = maxArg!(data)
1883+
1884+
if (isUnknown(value)) return null
1885+
return value >= min && value <= max
1886+
}
1887+
})
1888+
1889+
// Use in a query
1890+
const adultUsers = createLiveQueryCollection((q) =>
1891+
q
1892+
.from({ user: usersCollection })
1893+
.where(({ user }) => between(user.age, 18, 65))
1894+
)
1895+
```
1896+
1897+
You can also use the built-in factory helpers to create operators more concisely:
1898+
1899+
```ts
1900+
import { defineOperator, comparison, transform, numeric } from '@tanstack/db'
1901+
1902+
// Using the comparison helper (handles null/undefined automatically)
1903+
const notEquals = defineOperator<boolean>({
1904+
name: 'notEquals',
1905+
evaluate: comparison((a, b) => a !== b)
1906+
})
1907+
1908+
// Using the transform helper for unary operations
1909+
const double = defineOperator<number>({
1910+
name: 'double',
1911+
evaluate: transform((v) => typeof v === 'number' ? v * 2 : v)
1912+
})
1913+
1914+
// Using the numeric helper for binary math operations
1915+
const modulo = defineOperator<number>({
1916+
name: 'modulo',
1917+
evaluate: numeric((a, b) => b !== 0 ? a % b : null)
1918+
})
1919+
```
1920+
1921+
### Custom Aggregates
1922+
1923+
Similarly, you can define custom aggregate functions using `defineAggregate`:
1924+
1925+
```ts
1926+
import { defineAggregate } from '@tanstack/db'
1927+
1928+
// Define a "product" aggregate that multiplies all values
1929+
const product = defineAggregate<number>({
1930+
name: 'product',
1931+
factory: (valueExtractor) => ({
1932+
preMap: valueExtractor,
1933+
reduce: (values) => {
1934+
let result = 1
1935+
for (const [value, multiplicity] of values) {
1936+
for (let i = 0; i < multiplicity; i++) {
1937+
result *= value
1938+
}
1939+
}
1940+
return result
1941+
}
1942+
}),
1943+
valueTransform: 'numeric'
1944+
})
1945+
1946+
// Use in a query with groupBy
1947+
const categoryProducts = createLiveQueryCollection((q) =>
1948+
q
1949+
.from({ item: itemsCollection })
1950+
.groupBy(({ item }) => item.category)
1951+
.select(({ item }) => ({
1952+
category: item.category,
1953+
priceProduct: product(item.price)
1954+
}))
1955+
)
1956+
```
1957+
18691958
## Functional Variants
18701959

18711960
The functional variant API provides an alternative to the standard API, offering more flexibility for complex transformations. With functional variants, the callback functions contain actual code that gets executed to perform the operation, giving you the full power of JavaScript at your disposal.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"@typescript-eslint/eslint-plugin": "^8.51.0",
3838
"@typescript-eslint/parser": "^8.51.0",
3939
"@vitejs/plugin-react": "^5.1.2",
40+
"esbuild": "^0.27.2",
4041
"eslint": "^9.39.2",
4142
"eslint-import-resolver-typescript": "^4.4.4",
4243
"eslint-plugin-react": "^7.37.5",

packages/db/src/query/builder/aggregates/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Re-export all aggregates
2-
// Importing from here will auto-register all aggregate evaluators
2+
// Each aggregate is a function that creates Aggregate IR nodes with embedded configs
33

44
export { sum } from './sum.js'
55
export { count } from './count.js'

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Re-export all operators from their individual modules
2-
// Each module auto-registers its evaluator when imported
2+
// Each operator is a function that creates IR nodes with embedded evaluator factories
33
export { eq } from './operators/eq.js'
44
export { gt } from './operators/gt.js'
55
export { gte } from './operators/gte.js'
@@ -24,7 +24,6 @@ export { isNull } from './operators/isNull.js'
2424
export { isUndefined } from './operators/isUndefined.js'
2525

2626
// Re-export all aggregates from their individual modules
27-
// Each module auto-registers its config when imported
2827
export { count } from './aggregates/count.js'
2928
export { avg } from './aggregates/avg.js'
3029
export { sum } from './aggregates/sum.js'
Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,10 @@
11
import { Func } from '../../ir.js'
22
import { toExpression } from '../ref-proxy.js'
3-
import type { CompiledExpression } from '../../ir.js'
3+
import { numeric } from './factories.js'
4+
import type { EvaluatorFactory } from '../../ir.js'
45
import type { BinaryNumericReturnType, ExpressionLike } from './types.js'
56

6-
// ============================================================
7-
// EVALUATOR
8-
// ============================================================
9-
10-
function addEvaluatorFactory(
11-
compiledArgs: Array<CompiledExpression>,
12-
_isSingleRow: boolean,
13-
): CompiledExpression {
14-
const argA = compiledArgs[0]!
15-
const argB = compiledArgs[1]!
16-
17-
return (data: any) => {
18-
const a = argA(data)
19-
const b = argB(data)
20-
return (a ?? 0) + (b ?? 0)
21-
}
22-
}
23-
24-
// ============================================================
25-
// BUILDER FUNCTION
26-
// ============================================================
7+
const addFactory = /* #__PURE__*/ numeric((a, b) => a + b) as EvaluatorFactory
278

289
export function add<T1 extends ExpressionLike, T2 extends ExpressionLike>(
2910
left: T1,
@@ -32,6 +13,6 @@ export function add<T1 extends ExpressionLike, T2 extends ExpressionLike>(
3213
return new Func(
3314
`add`,
3415
[toExpression(left), toExpression(right)],
35-
addEvaluatorFactory,
16+
addFactory,
3617
) as BinaryNumericReturnType<T1, T2>
3718
}
Lines changed: 7 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,11 @@
11
import { Func } from '../../ir.js'
22
import { toExpression } from '../ref-proxy.js'
3-
import type { BasicExpression, CompiledExpression } from '../../ir.js'
3+
import { booleanOp } from './factories.js'
4+
import type { BasicExpression } from '../../ir.js'
5+
import type { ExpressionLike } from './types.js'
46

5-
// ============================================================
6-
// TYPES
7-
// ============================================================
8-
9-
// Helper type for any expression-like value
10-
type ExpressionLike = BasicExpression | any
11-
12-
// ============================================================
13-
// EVALUATOR
14-
// ============================================================
15-
16-
function isUnknown(value: any): boolean {
17-
return value === null || value === undefined
18-
}
19-
20-
function andEvaluatorFactory(
21-
compiledArgs: Array<CompiledExpression>,
22-
_isSingleRow: boolean,
23-
): CompiledExpression {
24-
return (data: any) => {
25-
// 3-valued logic for AND:
26-
// - false AND anything = false (short-circuit)
27-
// - null AND false = false
28-
// - null AND anything (except false) = null
29-
// - anything (except false) AND null = null
30-
// - true AND true = true
31-
let hasUnknown = false
32-
for (const compiledArg of compiledArgs) {
33-
const result = compiledArg(data)
34-
if (result === false) {
35-
return false
36-
}
37-
if (isUnknown(result)) {
38-
hasUnknown = true
39-
}
40-
}
41-
// If we got here, no operand was false
42-
// If any operand was null, return null (UNKNOWN)
43-
if (hasUnknown) {
44-
return null
45-
}
46-
47-
return true
48-
}
49-
}
50-
51-
// ============================================================
52-
// BUILDER FUNCTION
53-
// ============================================================
7+
// AND: short-circuits on false, returns true if all are true
8+
const andFactory = /* #__PURE__*/ booleanOp({ shortCircuit: false, default: true })
549

5510
// Overloads for and() - support 2 or more arguments, or an array
5611
export function and(
@@ -73,14 +28,14 @@ export function and(
7328
return new Func(
7429
`and`,
7530
leftOrArgs.map((arg) => toExpression(arg)),
76-
andEvaluatorFactory,
31+
andFactory,
7732
)
7833
}
7934
// Handle variadic overload
8035
const allArgs = [leftOrArgs, right!, ...rest]
8136
return new Func(
8237
`and`,
8338
allArgs.map((arg) => toExpression(arg)),
84-
andEvaluatorFactory,
39+
andFactory,
8540
)
8641
}

0 commit comments

Comments
 (0)