Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 52 additions & 38 deletions .changeset/auto-register-operators.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,61 +8,75 @@ Each operator and aggregate now bundles its builder function and evaluator facto

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

**Custom Operator Example:**

```typescript
import {
Func,
type EvaluatorFactory,
type CompiledExpression,
} from '@tanstack/db'
import { toExpression } from '@tanstack/db/query'
import { defineOperator, isUnknown } from '@tanstack/db'

const betweenFactory: EvaluatorFactory = (compiledArgs, _isSingleRow) => {
const [valueEval, minEval, maxEval] = compiledArgs
return (data) => {
const value = valueEval!(data)
return value >= minEval!(data) && value <= maxEval!(data)
}
}
// Define a custom "between" operator
const between = defineOperator<
boolean,
[value: number, min: number, max: number]
>({
name: 'between',
compile:
([valueArg, minArg, maxArg]) =>
(data) => {
const value = valueArg(data)
if (isUnknown(value)) return null
return value >= minArg(data) && value <= maxArg(data)
},
})

function between(value: any, min: any, max: any) {
return new Func(
'between',
[toExpression(value), toExpression(min), toExpression(max)],
betweenFactory,
)
}
// Use in a query
query.where(({ user }) => between(user.age, 18, 65))
```

**Using Factory Helpers:**

```typescript
import { defineOperator, comparison, transform, numeric } from '@tanstack/db'

// Binary comparison with automatic null handling
const notEquals = defineOperator<boolean, [a: unknown, b: unknown]>({
name: 'notEquals',
compile: comparison((a, b) => a !== b),
})

// Unary transformation
const double = defineOperator<number, [value: number]>({
name: 'double',
compile: transform((v) => v * 2),
})

// Binary numeric operation
const modulo = defineOperator<number, [a: number, b: number]>({
name: 'modulo',
compile: numeric((a, b) => (b !== 0 ? a % b : null)),
})
```

**Custom Aggregate Example:**

```typescript
import {
Aggregate,
type AggregateConfig,
type ValueExtractor,
} from '@tanstack/db'
import { toExpression } from '@tanstack/db/query'
import { defineAggregate } from '@tanstack/db'

const productConfig: AggregateConfig = {
factory: (valueExtractor: ValueExtractor) => ({
const product = defineAggregate<number>({
name: 'product',
factory: (valueExtractor) => ({
preMap: valueExtractor,
reduce: (values) => {
let product = 1
let result = 1
for (const [value, multiplicity] of values) {
for (let i = 0; i < multiplicity; i++) product *= value
for (let i = 0; i < multiplicity; i++) result *= value
}
return product
return result
},
}),
valueTransform: 'numeric',
}

function product<T>(arg: T): Aggregate<number> {
return new Aggregate('product', [toExpression(arg)], productConfig)
}
})
```
89 changes: 89 additions & 0 deletions docs/guides/live-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -1866,6 +1866,95 @@ concat(
avg(add(user.salary, coalesce(user.bonus, 0)))
```

### Custom Operators

You can define your own operators using the `defineOperator` function. This is useful when you need specialized comparison logic or domain-specific operations.

```ts
import { defineOperator, isUnknown } from '@tanstack/db'

// Define a custom "between" operator
const between = defineOperator<boolean, [value: number, min: number, max: number]>({
name: 'between',
compile: ([valueArg, minArg, maxArg]) => (data) => {
const value = valueArg(data)
const min = minArg(data)
const max = maxArg(data)

if (isUnknown(value)) return null
return value >= min && value <= max
}
})

// Use in a query
const adultUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.where(({ user }) => between(user.age, 18, 65))
)
```

You can also use the built-in factory helpers to create operators more concisely:

```ts
import { defineOperator, comparison, transform, numeric } from '@tanstack/db'

// Using the comparison helper (handles null/undefined automatically)
const notEquals = defineOperator<boolean, [a: unknown, b: unknown]>({
name: 'notEquals',
compile: comparison((a, b) => a !== b)
})

// Using the transform helper for unary operations
const double = defineOperator<number, [value: number]>({
name: 'double',
compile: transform((v) => v * 2)
})

// Using the numeric helper for binary math operations
const modulo = defineOperator<number, [a: number, b: number]>({
name: 'modulo',
compile: numeric((a, b) => b !== 0 ? a % b : null)
})
```

### Custom Aggregates

Similarly, you can define custom aggregate functions using `defineAggregate`:

```ts
import { defineAggregate } from '@tanstack/db'

// Define a "product" aggregate that multiplies all values
const product = defineAggregate<number>({
name: 'product',
factory: (valueExtractor) => ({
preMap: valueExtractor,
reduce: (values) => {
let result = 1
for (const [value, multiplicity] of values) {
for (let i = 0; i < multiplicity; i++) {
result *= value
}
}
return result
}
}),
valueTransform: 'numeric'
})

// Use in a query with groupBy
const categoryProducts = createLiveQueryCollection((q) =>
q
.from({ item: itemsCollection })
.groupBy(({ item }) => item.category)
.select(({ item }) => ({
category: item.category,
priceProduct: product(item.price)
}))
)
```

## Functional Variants

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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@typescript-eslint/eslint-plugin": "^8.51.0",
"@typescript-eslint/parser": "^8.51.0",
"@vitejs/plugin-react": "^5.1.2",
"esbuild": "^0.27.2",
"eslint": "^9.39.2",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-react": "^7.37.5",
Expand Down
2 changes: 1 addition & 1 deletion packages/db/src/query/builder/aggregates/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Re-export all aggregates
// Importing from here will auto-register all aggregate evaluators
// Each aggregate is a function that creates Aggregate IR nodes with embedded configs

export { sum } from './sum.js'
export { count } from './count.js'
Expand Down
3 changes: 1 addition & 2 deletions packages/db/src/query/builder/functions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Re-export all operators from their individual modules
// Each module auto-registers its evaluator when imported
// Each operator is a function that creates IR nodes with embedded evaluator factories
export { eq } from './operators/eq.js'
export { gt } from './operators/gt.js'
export { gte } from './operators/gte.js'
Expand All @@ -24,7 +24,6 @@ export { isNull } from './operators/isNull.js'
export { isUndefined } from './operators/isUndefined.js'

// Re-export all aggregates from their individual modules
// Each module auto-registers its config when imported
export { count } from './aggregates/count.js'
export { avg } from './aggregates/avg.js'
export { sum } from './aggregates/sum.js'
Expand Down
27 changes: 4 additions & 23 deletions packages/db/src/query/builder/operators/add.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,10 @@
import { Func } from '../../ir.js'
import { toExpression } from '../ref-proxy.js'
import type { CompiledExpression } from '../../ir.js'
import { numeric } from './factories.js'
import type { EvaluatorFactory } from '../../ir.js'
import type { BinaryNumericReturnType, ExpressionLike } from './types.js'

// ============================================================
// EVALUATOR
// ============================================================

function addEvaluatorFactory(
compiledArgs: Array<CompiledExpression>,
_isSingleRow: boolean,
): CompiledExpression {
const argA = compiledArgs[0]!
const argB = compiledArgs[1]!

return (data: any) => {
const a = argA(data)
const b = argB(data)
return (a ?? 0) + (b ?? 0)
}
}

// ============================================================
// BUILDER FUNCTION
// ============================================================
const addFactory = /* #__PURE__*/ numeric((a, b) => a + b) as EvaluatorFactory

export function add<T1 extends ExpressionLike, T2 extends ExpressionLike>(
left: T1,
Expand All @@ -32,6 +13,6 @@ export function add<T1 extends ExpressionLike, T2 extends ExpressionLike>(
return new Func(
`add`,
[toExpression(left), toExpression(right)],
addEvaluatorFactory,
addFactory,
) as BinaryNumericReturnType<T1, T2>
}
62 changes: 10 additions & 52 deletions packages/db/src/query/builder/operators/and.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,14 @@
import { Func } from '../../ir.js'
import { toExpression } from '../ref-proxy.js'
import type { BasicExpression, CompiledExpression } from '../../ir.js'
import { booleanOp } from './factories.js'
import type { BasicExpression } from '../../ir.js'
import type { ExpressionLike } from './types.js'

// ============================================================
// TYPES
// ============================================================

// Helper type for any expression-like value
type ExpressionLike = BasicExpression | any

// ============================================================
// EVALUATOR
// ============================================================

function isUnknown(value: any): boolean {
return value === null || value === undefined
}

function andEvaluatorFactory(
compiledArgs: Array<CompiledExpression>,
_isSingleRow: boolean,
): CompiledExpression {
return (data: any) => {
// 3-valued logic for AND:
// - false AND anything = false (short-circuit)
// - null AND false = false
// - null AND anything (except false) = null
// - anything (except false) AND null = null
// - true AND true = true
let hasUnknown = false
for (const compiledArg of compiledArgs) {
const result = compiledArg(data)
if (result === false) {
return false
}
if (isUnknown(result)) {
hasUnknown = true
}
}
// If we got here, no operand was false
// If any operand was null, return null (UNKNOWN)
if (hasUnknown) {
return null
}

return true
}
}

// ============================================================
// BUILDER FUNCTION
// ============================================================
// AND: short-circuits on false, returns true if all are true
const andFactory = /* #__PURE__*/ booleanOp({
shortCircuit: false,
default: true,
})

// Overloads for and() - support 2 or more arguments, or an array
export function and(
Expand All @@ -73,14 +31,14 @@ export function and(
return new Func(
`and`,
leftOrArgs.map((arg) => toExpression(arg)),
andEvaluatorFactory,
andFactory,
)
}
// Handle variadic overload
const allArgs = [leftOrArgs, right!, ...rest]
return new Func(
`and`,
allArgs.map((arg) => toExpression(arg)),
andEvaluatorFactory,
andFactory,
)
}
Loading
Loading