From 4903693f8d787103c3a4efe7ed91dc29de04c2e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Jan 2026 00:57:01 +0000 Subject: [PATCH 1/6] feat(db): add subtract, multiply, divide math functions Add missing math functions that were implemented in evaluators but not exported. These enable computed columns in orderBy for ranking algorithms like HN-style scoring that balances recency and rating. - Add subtract(a, b) function - Add multiply(a, b) function - Add divide(a, b) function (with null on divide-by-zero) - Export from query/index.ts - Add to operators list - Add comprehensive tests including orderBy usage --- packages/db/src/query/builder/functions.ts | 33 +++++++++ packages/db/src/query/index.ts | 3 + .../db/tests/query/builder/functions.test.ts | 72 +++++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/packages/db/src/query/builder/functions.ts b/packages/db/src/query/builder/functions.ts index 41ce11370..2fcffb1d7 100644 --- a/packages/db/src/query/builder/functions.ts +++ b/packages/db/src/query/builder/functions.ts @@ -302,6 +302,36 @@ export function add( ]) 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, +): BinaryNumericReturnType { + return new Func(`divide`, [ + toExpression(left), + toExpression(right), + ]) as BinaryNumericReturnType +} + // Aggregates export function count(arg: ExpressionLike): Aggregate { @@ -365,6 +395,9 @@ export const operators = [ `concat`, // Numeric functions `add`, + `subtract`, + `multiply`, + `divide`, // Utility functions `coalesce`, // Aggregate functions diff --git a/packages/db/src/query/index.ts b/packages/db/src/query/index.ts index 524b4dcf1..30d43c266 100644 --- a/packages/db/src/query/index.ts +++ b/packages/db/src/query/index.ts @@ -37,6 +37,9 @@ export { concat, coalesce, 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 fb6cb4f35..3685191ae 100644 --- a/packages/db/tests/query/builder/functions.test.ts +++ b/packages/db/tests/query/builder/functions.test.ts @@ -8,6 +8,7 @@ import { coalesce, concat, count, + divide, eq, gt, gte, @@ -19,8 +20,10 @@ import { lte, max, min, + multiply, not, or, + subtract, sum, upper, } from '../../../src/query/builder/functions.js' @@ -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`) + }) }) }) From 4874663a11d7e73a09caaed54762f9446562c8b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Jan 2026 01:02:05 +0000 Subject: [PATCH 2/6] docs: document subtract, multiply, divide math functions - Add documentation for new math functions in live-queries.md - Include example of computed columns in orderBy for ranking algorithms - Add changeset for the new minor feature --- .changeset/add-math-functions.md | 25 +++++++++++++++++ docs/guides/live-queries.md | 46 ++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 .changeset/add-math-functions.md diff --git a/.changeset/add-math-functions.md b/.changeset/add-math-functions.md new file mode 100644 index 000000000..4073a5291 --- /dev/null +++ b/.changeset/add-math-functions.md @@ -0,0 +1,25 @@ +--- +"@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 b69689e16..89dbbc3d4 100644 --- a/docs/guides/live-queries.md +++ b/docs/guides/live-queries.md @@ -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)` From 287a1ffca30dfc1adc130d2f6983e0dcf5283001 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 18 Jan 2026 05:36:19 +0000 Subject: [PATCH 3/6] ci: apply automated fixes --- .changeset/add-math-functions.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.changeset/add-math-functions.md b/.changeset/add-math-functions.md index 4073a5291..c4e70d0c5 100644 --- a/.changeset/add-math-functions.md +++ b/.changeset/add-math-functions.md @@ -1,5 +1,5 @@ --- -"@tanstack/db": patch +'@tanstack/db': patch --- Add `subtract`, `multiply`, and `divide` math functions for computed columns @@ -14,9 +14,10 @@ const ranked = createLiveQueryCollection((q) => q .from({ r: recipesCollection }) .orderBy( - ({ r }) => subtract(multiply(r.rating, r.timesMade), divide(r.ageInMs, 86400000)), - 'desc' - ) + ({ r }) => + subtract(multiply(r.rating, r.timesMade), divide(r.ageInMs, 86400000)), + 'desc', + ), ) ``` From 6b370407e1e14bef7d01c536ab8c81925be5fc4d Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 17 Jun 2026 14:49:02 -0600 Subject: [PATCH 4/6] fix: align math function return types --- packages/db/src/query/builder/functions.ts | 47 ++++++------------- .../db/tests/query/builder/functions.test.ts | 20 +++++++- 2 files changed, 33 insertions(+), 34 deletions(-) diff --git a/packages/db/src/query/builder/functions.ts b/packages/db/src/query/builder/functions.ts index ee013f979..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,41 +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 { +): BinaryNumericReturnType { return new Func(`subtract`, [ toExpression(left), toExpression(right), - ]) as BinaryNumericReturnType + ]) as BinaryNumericReturnType } export function multiply( left: T1, right: T2, -): BinaryNumericReturnType { +): BinaryNumericReturnType { return new Func(`multiply`, [ toExpression(left), toExpression(right), - ]) as BinaryNumericReturnType + ]) as BinaryNumericReturnType } export function divide( left: T1, right: T2, -): BinaryNumericReturnType { +): DivideReturnType { return new Func(`divide`, [ toExpression(left), toExpression(right), - ]) as BinaryNumericReturnType + ]) as DivideReturnType } // Aggregates diff --git a/packages/db/tests/query/builder/functions.test.ts b/packages/db/tests/query/builder/functions.test.ts index fd64d2e15..d4d70afe1 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 { @@ -32,6 +32,8 @@ import { 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 { @@ -381,6 +383,22 @@ describe(`QueryBuilder Functions`, () => { 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>() + + const divideResult = compileSingleRowExpression(divideExpression)({}) + expect(divideResult).toBeNull() + }) + it(`math functions can be used in orderBy`, () => { const query = new Query() .from({ employees: employeesCollection }) From 6b601e57a7062116b1770049e8f37bcd3c278fb8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:50:15 +0000 Subject: [PATCH 5/6] ci: apply automated fixes --- packages/db/tests/query/builder/functions.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/db/tests/query/builder/functions.test.ts b/packages/db/tests/query/builder/functions.test.ts index d4d70afe1..e4d284960 100644 --- a/packages/db/tests/query/builder/functions.test.ts +++ b/packages/db/tests/query/builder/functions.test.ts @@ -393,7 +393,9 @@ describe(`QueryBuilder Functions`, () => { it(`RED review: divide can return null for non-null operand types`, () => { const divideExpression = divide(10, 0) - expectTypeOf(divideExpression).toEqualTypeOf>() + expectTypeOf(divideExpression).toEqualTypeOf< + BasicExpression + >() const divideResult = compileSingleRowExpression(divideExpression)({}) expect(divideResult).toBeNull() From 771e02e0a4b26307b0915d8e3173df305aff0200 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 17 Jun 2026 16:18:19 -0600 Subject: [PATCH 6/6] docs: clarify ranking snapshot semantics --- docs/guides/live-queries.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md index 737c5b264..988628261 100644 --- a/docs/guides/live-queries.md +++ b/docs/guides/live-queries.md @@ -2577,6 +2577,8 @@ You can use math functions directly in `orderBy` to sort by computed values. Thi 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 })