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)
46 changes: 46 additions & 0 deletions docs/guides/live-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -1816,12 +1816,58 @@ 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)
```

#### `coalesce(...values)`
Return the first non-null value:
```ts
coalesce(user.displayName, user.name, 'Unknown')
```

#### 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
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
)
),
'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.

### Aggregate Functions

#### `count(value)`
Expand Down
33 changes: 33 additions & 0 deletions packages/db/src/query/builder/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,36 @@ export function add<T1 extends ExpressionLike, T2 extends ExpressionLike>(
]) as BinaryNumericReturnType<T1, T2>
}

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

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

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

// Aggregates

export function count(arg: ExpressionLike): Aggregate<number> {
Expand Down Expand Up @@ -365,6 +395,9 @@ export const operators = [
`concat`,
// Numeric functions
`add`,
`subtract`,
`multiply`,
`divide`,
// Utility functions
`coalesce`,
// Aggregate functions
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 @@ -37,6 +37,9 @@ export {
concat,
coalesce,
add,
subtract,
multiply,
divide,
// Aggregates
count,
avg,
Expand Down
72 changes: 72 additions & 0 deletions packages/db/tests/query/builder/functions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
coalesce,
concat,
count,
divide,
eq,
gt,
gte,
Expand All @@ -19,8 +20,10 @@ import {
lte,
max,
min,
multiply,
not,
or,
subtract,
sum,
upper,
} from '../../../src/query/builder/functions.js'
Expand Down Expand Up @@ -289,5 +292,74 @@ 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`)
})

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(`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