diff --git a/.changeset/add-math-functions.md b/.changeset/add-math-functions.md new file mode 100644 index 000000000..c4e70d0c5 --- /dev/null +++ b/.changeset/add-math-functions.md @@ -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) diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md index 0780871a3..988628261 100644 --- a/docs/guides/live-queries.md +++ b/docs/guides/live-queries.md @@ -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 + ) + ), + '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)` diff --git a/packages/db/src/query/builder/functions.ts b/packages/db/src/query/builder/functions.ts index 47e190162..84c26e5ca 100644 --- a/packages/db/src/query/builder/functions.ts +++ b/packages/db/src/query/builder/functions.ts @@ -125,31 +125,12 @@ type MapToNumber = T extends string | Array ? null : T -// Helper type for binary numeric operations (combines nullability of both operands) -type BinaryNumericReturnType = - ExtractType extends infer U1 - ? ExtractType extends infer U2 - ? U1 extends number - ? U2 extends number - ? BasicExpression - : U2 extends number | undefined - ? BasicExpression - : U2 extends number | null - ? BasicExpression - : BasicExpression - : U1 extends number | undefined - ? U2 extends number - ? BasicExpression - : U2 extends number | undefined - ? BasicExpression - : BasicExpression - : U1 extends number | null - ? U2 extends number - ? BasicExpression - : BasicExpression - : BasicExpression - : BasicExpression - : BasicExpression +// 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 + +type DivideReturnType = BasicExpression // Operators @@ -620,11 +601,41 @@ export function caseWhen(...args: Array): any { export function add( left: T1, right: T2, -): BinaryNumericReturnType { +): BinaryNumericReturnType { return new Func(`add`, [ toExpression(left), toExpression(right), - ]) as BinaryNumericReturnType + ]) as BinaryNumericReturnType +} + +export function subtract( + left: T1, + right: T2, +): BinaryNumericReturnType { + return new Func(`subtract`, [ + toExpression(left), + toExpression(right), + ]) as BinaryNumericReturnType +} + +export function multiply( + left: T1, + right: T2, +): BinaryNumericReturnType { + return new Func(`multiply`, [ + toExpression(left), + toExpression(right), + ]) as BinaryNumericReturnType +} + +export function divide( + left: T1, + right: T2, +): DivideReturnType { + return new Func(`divide`, [ + toExpression(left), + toExpression(right), + ]) as DivideReturnType } // Aggregates @@ -690,6 +701,9 @@ export const operators = [ `concat`, // Numeric functions `add`, + `subtract`, + `multiply`, + `divide`, // Utility functions `coalesce`, `caseWhen`, diff --git a/packages/db/src/query/index.ts b/packages/db/src/query/index.ts index e23475bc2..8cda812b3 100644 --- a/packages/db/src/query/index.ts +++ b/packages/db/src/query/index.ts @@ -59,6 +59,9 @@ export { coalesce, caseWhen, add, + subtract, + multiply, + divide, // Aggregates count, avg, diff --git a/packages/db/tests/query/builder/functions.test.ts b/packages/db/tests/query/builder/functions.test.ts index cd40ce2e4..e4d284960 100644 --- a/packages/db/tests/query/builder/functions.test.ts +++ b/packages/db/tests/query/builder/functions.test.ts @@ -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 { @@ -12,6 +12,7 @@ import { coalesce, concat, count, + divide, eq, gt, gte, @@ -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 { @@ -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`) + }) + + 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>() + + 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 + >() + + 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`) + }) }) })