Skip to content
Open
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
26 changes: 26 additions & 0 deletions .changeset/add-math-functions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@tanstack/db': patch
---

Add `subtract`, `multiply`, and `divide` math functions for computed columns

These functions enable complex calculations in `select` and `orderBy` clauses, such as ranking algorithms that combine multiple factors (e.g., HN-style scoring that balances recency and rating).

```ts
import { subtract, multiply, divide } from '@tanstack/db'

// Example: Sort by computed ranking score
const ranked = createLiveQueryCollection((q) =>
q
.from({ r: recipesCollection })
.orderBy(
({ r }) =>
subtract(multiply(r.rating, r.timesMade), divide(r.ageInMs, 86400000)),
'desc',
),
)
```

- `subtract(a, b)` - Subtraction
- `multiply(a, b)` - Multiplication
- `divide(a, b)` - Division (returns `null` on divide-by-zero)
48 changes: 48 additions & 0 deletions docs/guides/live-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -2551,6 +2551,54 @@ Add two numbers:
add(user.salary, user.bonus)
```

#### `subtract(left, right)`
Subtract two numbers:
```ts
subtract(user.salary, user.deductions)
```

#### `multiply(left, right)`
Multiply two numbers:
```ts
multiply(item.price, item.quantity)
```

#### `divide(left, right)`
Divide two numbers (returns `null` on divide-by-zero):
```ts
divide(order.total, order.itemCount)
```

#### Computed Columns in orderBy

You can use math functions directly in `orderBy` to sort by computed values. This is useful for ranking algorithms that combine multiple factors:

```ts
import { subtract, multiply, divide } from '@tanstack/db'

// HN-style ranking: balance rating with recency
// Date.now() is captured when this query is created. Recreate the query if
// you need the recency score to advance as time passes.
const rankedRecipes = createLiveQueryCollection((q) =>
q
.from({ r: recipesCollection })
.orderBy(
({ r }) =>
subtract(
multiply(r.rating, r.timesMade), // weighted rating
divide(
subtract(Date.now(), r.lastMadeAt), // time since last made
3600000 * 24 // convert ms to days
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
),
'desc'
)
.limit(20)
)
```

> **Note:** When using computed expressions in `orderBy` with `limit()`, lazy loading optimization is skipped (all matching data is loaded first, then sorted). For large collections where this matters, consider pre-computing the ranking score as a stored field.

### Utility Functions

#### `coalesce(...values)`
Expand Down
68 changes: 41 additions & 27 deletions packages/db/src/query/builder/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,31 +125,12 @@ type MapToNumber<T> = T extends string | Array<any>
? null
: T

// Helper type for binary numeric operations (combines nullability of both operands)
type BinaryNumericReturnType<T1, T2> =
ExtractType<T1> extends infer U1
? ExtractType<T2> extends infer U2
? U1 extends number
? U2 extends number
? BasicExpression<number>
: U2 extends number | undefined
? BasicExpression<number | undefined>
: U2 extends number | null
? BasicExpression<number | null>
: BasicExpression<number | undefined | null>
: U1 extends number | undefined
? U2 extends number
? BasicExpression<number | undefined>
: U2 extends number | undefined
? BasicExpression<number | undefined>
: BasicExpression<number | undefined | null>
: U1 extends number | null
? U2 extends number
? BasicExpression<number | null>
: BasicExpression<number | undefined | null>
: BasicExpression<number | undefined | null>
: BasicExpression<number | undefined | null>
: BasicExpression<number | undefined | null>
// Helper type for binary numeric operations.
// Runtime coalesces nullish operands to 0 for these operations, so nullable
// operands don't make the result nullable.
type BinaryNumericReturnType = BasicExpression<number>

type DivideReturnType = BasicExpression<number | null>
Comment on lines +128 to +133

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the runtime evaluator coalesces null/undefined to 0 for add/subtract/multiply
rg -nP --type=ts -C10 'add|subtract|multiply|divide' packages/db/src/query/compiler/evaluators.ts | head -100

Repository: TanStack/db

Length of output: 1382


Clarify PR objectives regarding nullability propagation.

The code comment and type definitions are correct—the runtime does coalesce null/undefined operands to 0 for add, subtract, and multiply operations using the nullish coalescing operator (??), making the results non-nullable. However, the PR objectives state "nullability propagates correctly when either operand can be null or undefined," which is inaccurate for these operations. Nullability is coalesced away, not propagated. Update the PR objectives to accurately reflect that nullability does not propagate for add/subtract/multiply, while divide correctly remains nullable due to division-by-zero semantics.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/query/builder/functions.ts` around lines 128 - 133, Update
the PR objectives or description to accurately reflect the nullability behavior
in the binary numeric operations. Clarify that for the add, subtract, and
multiply operations (which use the nullish coalescing operator), nullability is
coalesced away to 0 rather than propagated—making the BinaryNumericReturnType
correctly non-nullable. Additionally, note that the DivideReturnType correctly
remains nullable due to division-by-zero semantics. Ensure the PR objectives no
longer claim that "nullability propagates correctly" for these operations since
that misrepresents the actual behavior.


// Operators

Expand Down Expand Up @@ -620,11 +601,41 @@ export function caseWhen(...args: Array<CaseWhenValue>): any {
export function add<T1 extends ExpressionLike, T2 extends ExpressionLike>(
left: T1,
right: T2,
): BinaryNumericReturnType<T1, T2> {
): BinaryNumericReturnType {
return new Func(`add`, [
toExpression(left),
toExpression(right),
]) as BinaryNumericReturnType<T1, T2>
]) as BinaryNumericReturnType
}

export function subtract<T1 extends ExpressionLike, T2 extends ExpressionLike>(
left: T1,
right: T2,
): BinaryNumericReturnType {
return new Func(`subtract`, [
toExpression(left),
toExpression(right),
]) as BinaryNumericReturnType
}

export function multiply<T1 extends ExpressionLike, T2 extends ExpressionLike>(
left: T1,
right: T2,
): BinaryNumericReturnType {
return new Func(`multiply`, [
toExpression(left),
toExpression(right),
]) as BinaryNumericReturnType
}

export function divide<T1 extends ExpressionLike, T2 extends ExpressionLike>(
left: T1,
right: T2,
): DivideReturnType {
return new Func(`divide`, [
toExpression(left),
toExpression(right),
]) as DivideReturnType
}

// Aggregates
Expand Down Expand Up @@ -690,6 +701,9 @@ export const operators = [
`concat`,
// Numeric functions
`add`,
`subtract`,
`multiply`,
`divide`,
// Utility functions
`coalesce`,
`caseWhen`,
Expand Down
3 changes: 3 additions & 0 deletions packages/db/src/query/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ export {
coalesce,
caseWhen,
add,
subtract,
multiply,
divide,
// Aggregates
count,
avg,
Expand Down
94 changes: 93 additions & 1 deletion packages/db/tests/query/builder/functions.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, expectTypeOf, it } from 'vitest'
import { CollectionImpl } from '../../../src/collection/index.js'
import { Query, getQueryIR } from '../../../src/query/builder/index.js'
import {
Expand All @@ -12,6 +12,7 @@ import {
coalesce,
concat,
count,
divide,
eq,
gt,
gte,
Expand All @@ -23,12 +24,16 @@ import {
lte,
max,
min,
multiply,
not,
or,
subtract,
sum,
toArray,
upper,
} from '../../../src/query/builder/functions.js'
import { compileSingleRowExpression } from '../../../src/query/compiler/evaluators.js'
import type { BasicExpression } from '../../../src/query/ir.js'

// Test schema
interface Employee {
Expand Down Expand Up @@ -324,5 +329,92 @@ describe(`QueryBuilder Functions`, () => {
const select = builtQuery.select!
expect((select.salary_plus_bonus as any).name).toBe(`add`)
})

it(`subtract function works`, () => {
const query = new Query()
.from({ employees: employeesCollection })
.select(({ employees }) => ({
id: employees.id,
salary_minus_tax: subtract(employees.salary, 5000),
}))

const builtQuery = getQueryIR(query)
const select = builtQuery.select!
expect((select.salary_minus_tax as any).name).toBe(`subtract`)
})

it(`multiply function works`, () => {
const query = new Query()
.from({ employees: employeesCollection })
.select(({ employees }) => ({
id: employees.id,
double_salary: multiply(employees.salary, 2),
}))

const builtQuery = getQueryIR(query)
const select = builtQuery.select!
expect((select.double_salary as any).name).toBe(`multiply`)
})

it(`divide function works`, () => {
const query = new Query()
.from({ employees: employeesCollection })
.select(({ employees }) => ({
id: employees.id,
monthly_salary: divide(employees.salary, 12),
}))

const builtQuery = getQueryIR(query)
const select = builtQuery.select!
expect((select.monthly_salary as any).name).toBe(`divide`)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it(`math functions can be combined for complex calculations`, () => {
const query = new Query()
.from({ employees: employeesCollection })
.select(({ employees }) => ({
id: employees.id,
// (salary * 1.1) - 500 = 10% raise minus deductions
adjusted_salary: subtract(multiply(employees.salary, 1.1), 500),
}))

const builtQuery = getQueryIR(query)
const select = builtQuery.select!
expect((select.adjusted_salary as any).name).toBe(`subtract`)
})

it(`RED review: nullish operands are coalesced to 0 at runtime but widen types`, () => {
const subtractExpression = subtract(10, null)
expectTypeOf(subtractExpression).toEqualTypeOf<BasicExpression<number>>()

const subtractResult = compileSingleRowExpression(subtractExpression)({})
expect(subtractResult).toBe(10)
})

it(`RED review: divide can return null for non-null operand types`, () => {
const divideExpression = divide(10, 0)
expectTypeOf(divideExpression).toEqualTypeOf<
BasicExpression<number | null>
>()

const divideResult = compileSingleRowExpression(divideExpression)({})
expect(divideResult).toBeNull()
})

it(`math functions can be used in orderBy`, () => {
const query = new Query()
.from({ employees: employeesCollection })
.orderBy(({ employees }) => multiply(employees.salary, 2), `desc`)
.select(({ employees }) => ({
id: employees.id,
salary: employees.salary,
}))

const builtQuery = getQueryIR(query)
expect(builtQuery.orderBy).toBeDefined()
expect(builtQuery.orderBy).toHaveLength(1)
expect((builtQuery.orderBy![0]!.expression as any).name).toBe(`multiply`)
expect(builtQuery.orderBy![0]!.compareOptions.direction).toBe(`desc`)
})
})
})
Loading