diff --git a/.changeset/auto-register-operators.md b/.changeset/auto-register-operators.md new file mode 100644 index 000000000..8ce3cbfa4 --- /dev/null +++ b/.changeset/auto-register-operators.md @@ -0,0 +1,68 @@ +--- +'@tanstack/db': patch +--- + +Refactor operators and aggregates to embed their evaluators directly in IR nodes for true tree-shaking support and custom extensibility. + +Each operator and aggregate now bundles its builder function and evaluator factory in a single file. The factory is embedded directly in the `Func` or `Aggregate` IR node, eliminating the need for a global registry. This enables: + +- **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 Operator Example:** + +```typescript +import { + Func, + type EvaluatorFactory, + type CompiledExpression, +} from '@tanstack/db' +import { toExpression } from '@tanstack/db/query' + +const betweenFactory: EvaluatorFactory = (compiledArgs, _isSingleRow) => { + const [valueEval, minEval, maxEval] = compiledArgs + return (data) => { + const value = valueEval!(data) + return value >= minEval!(data) && value <= maxEval!(data) + } +} + +function between(value: any, min: any, max: any) { + return new Func( + 'between', + [toExpression(value), toExpression(min), toExpression(max)], + betweenFactory, + ) +} +``` + +**Custom Aggregate Example:** + +```typescript +import { + Aggregate, + type AggregateConfig, + type ValueExtractor, +} from '@tanstack/db' +import { toExpression } from '@tanstack/db/query' + +const productConfig: AggregateConfig = { + factory: (valueExtractor: ValueExtractor) => ({ + preMap: valueExtractor, + reduce: (values) => { + let product = 1 + for (const [value, multiplicity] of values) { + for (let i = 0; i < multiplicity; i++) product *= value + } + return product + }, + }), + valueTransform: 'numeric', +} + +function product(arg: T): Aggregate { + return new Aggregate('product', [toExpression(arg)], productConfig) +} +``` diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md index b69689e16..9109b1d9c 100644 --- a/docs/guides/live-queries.md +++ b/docs/guides/live-queries.md @@ -1075,7 +1075,7 @@ const userStats = createCollection(liveQueryCollectionOptions({ Use various aggregate functions to summarize your data: ```ts -import { count, sum, avg, min, max } from '@tanstack/db' +import { count, sum, avg, min, max, minStr, maxStr, collect } from '@tanstack/db' const orderStats = createCollection(liveQueryCollectionOptions({ query: (q) => @@ -1846,12 +1846,26 @@ avg(order.amount) ``` #### `min(value)`, `max(value)` -Find minimum and maximum values: +Find minimum and maximum values (coerces to numbers): ```ts min(user.salary) max(order.amount) ``` +#### `minStr(value)`, `maxStr(value)` +Find minimum and maximum using string comparison. Unlike `min`/`max` which coerce values to numbers, these preserve string comparison: +```ts +minStr(event.createdAt) // Works correctly with ISO 8601 timestamps +maxStr(item.code) // Lexicographic comparison for any string +``` + +#### `collect(value)` +Collect all values in a group into an array: +```ts +collect(order.id) // Array of all order IDs in group +collect(order.amount) // Array of all amounts +``` + ### Function Composition Functions can be composed and chained: diff --git a/packages/db-ivm/src/operators/groupBy.ts b/packages/db-ivm/src/operators/groupBy.ts index 24b884715..05559c2ac 100644 --- a/packages/db-ivm/src/operators/groupBy.ts +++ b/packages/db-ivm/src/operators/groupBy.ts @@ -367,6 +367,32 @@ export function mode( } } +/** + * Creates a collect aggregate function that gathers all values into an array + * Similar to SQL's array_agg or GROUP_CONCAT + * @param valueExtractor Function to extract a value from each data entry + */ +export function collect( + valueExtractor: (value: T) => V = (v) => v as unknown as V, +): AggregateFunction, Array> { + return { + preMap: (data: T) => [valueExtractor(data)], + reduce: (values: Array<[Array, number]>) => { + const allValues: Array = [] + for (const [valueArray, multiplicity] of values) { + for (const value of valueArray) { + // Add each value 'multiplicity' times for correct IVM semantics + for (let i = 0; i < multiplicity; i++) { + allValues.push(value) + } + } + } + return allValues + }, + // No postMap - return the array directly + } +} + export const groupByOperators = { sum, count, @@ -375,4 +401,5 @@ export const groupByOperators = { max, median, mode, + collect, } diff --git a/packages/db-ivm/tests/operators/groupBy.test.ts b/packages/db-ivm/tests/operators/groupBy.test.ts index fbe50fb33..d92151bbc 100644 --- a/packages/db-ivm/tests/operators/groupBy.test.ts +++ b/packages/db-ivm/tests/operators/groupBy.test.ts @@ -3,6 +3,7 @@ import { D2 } from '../../src/d2.js' import { MultiSet } from '../../src/multiset.js' import { avg, + collect, count, groupBy, max, @@ -662,6 +663,79 @@ describe(`Operators`, () => { expect(latestMessage.getInner()).toEqual(expectedResult) }) + test(`with collect aggregate`, () => { + const graph = new D2() + const input = graph.newInput<{ + category: string + item: string + }>() + let latestMessage: any = null + + input.pipe( + groupBy((data) => ({ category: data.category }), { + items: collect((data) => data.item), + }), + output((message) => { + latestMessage = message + }), + ) + + graph.finalize() + + input.sendData( + new MultiSet([ + [{ category: `A`, item: `apple` }, 1], + [{ category: `A`, item: `avocado` }, 1], + [{ category: `B`, item: `banana` }, 1], + ]), + ) + graph.run() + + expect(latestMessage).not.toBeNull() + + const result = latestMessage.getInner() + expect(result).toHaveLength(2) + + const categoryA = result.find( + ([key]: any) => key[0] === `{"category":"A"}`, + ) + expect(categoryA).toBeDefined() + expect(categoryA[0][1].items).toEqual([`apple`, `avocado`]) + + const categoryB = result.find( + ([key]: any) => key[0] === `{"category":"B"}`, + ) + expect(categoryB).toBeDefined() + expect(categoryB[0][1].items).toEqual([`banana`]) + + // Add another item to category A + input.sendData(new MultiSet([[{ category: `A`, item: `apricot` }, 1]])) + graph.run() + + const updatedResult = latestMessage.getInner() + // IVM returns deltas: -1 for old state, +1 for new state + const updatedCategoryA = updatedResult.find( + ([[key], weight]: any) => key === `{"category":"A"}` && weight === 1, + ) + expect(updatedCategoryA).toBeDefined() + expect(updatedCategoryA[0][1].items).toEqual([ + `apple`, + `avocado`, + `apricot`, + ]) + + // Remove an item from category A + input.sendData(new MultiSet([[{ category: `A`, item: `apple` }, -1]])) + graph.run() + + const afterRemoval = latestMessage.getInner() + const categoryAAfterRemoval = afterRemoval.find( + ([[key], weight]: any) => key === `{"category":"A"}` && weight === 1, + ) + expect(categoryAAfterRemoval).toBeDefined() + expect(categoryAAfterRemoval[0][1].items).toEqual([`avocado`, `apricot`]) + }) + test(`complete group removal with sum aggregate`, () => { const graph = new D2() const input = graph.newInput<{ diff --git a/packages/db/src/query/builder/aggregates/avg.ts b/packages/db/src/query/builder/aggregates/avg.ts new file mode 100644 index 000000000..37437ebc3 --- /dev/null +++ b/packages/db/src/query/builder/aggregates/avg.ts @@ -0,0 +1,25 @@ +import { groupByOperators } from '@tanstack/db-ivm' +import { Aggregate } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { AggregateReturnType, ExpressionLike } from '../operators/types.js' + +// ============================================================ +// CONFIG +// ============================================================ + +const avgConfig = { + factory: groupByOperators.avg, + valueTransform: `numeric` as const, +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function avg(arg: T): AggregateReturnType { + return new Aggregate( + `avg`, + [toExpression(arg)], + avgConfig, + ) as AggregateReturnType +} diff --git a/packages/db/src/query/builder/aggregates/collect.ts b/packages/db/src/query/builder/aggregates/collect.ts new file mode 100644 index 000000000..f6852f900 --- /dev/null +++ b/packages/db/src/query/builder/aggregates/collect.ts @@ -0,0 +1,39 @@ +import { groupByOperators } from '@tanstack/db-ivm' +import { Aggregate } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { ExpressionLike, ExtractType } from '../operators/types.js' + +// ============================================================ +// CONFIG +// ============================================================ + +const collectConfig = { + factory: groupByOperators.collect, + valueTransform: `raw` as const, +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +/** + * Collects all values in a group into an array. + * Similar to SQL's array_agg or GROUP_CONCAT. + * + * @example + * ```typescript + * // Collect all posts for each user + * query + * .from({ posts: postsCollection }) + * .groupBy(({ posts }) => posts.userId) + * .select(({ posts }) => ({ + * userId: posts.userId, + * allPosts: collect(posts), + * })) + * ``` + */ +export function collect( + arg: T, +): Aggregate>> { + return new Aggregate(`collect`, [toExpression(arg)], collectConfig) +} diff --git a/packages/db/src/query/builder/aggregates/count.ts b/packages/db/src/query/builder/aggregates/count.ts new file mode 100644 index 000000000..fbc6b47e0 --- /dev/null +++ b/packages/db/src/query/builder/aggregates/count.ts @@ -0,0 +1,21 @@ +import { groupByOperators } from '@tanstack/db-ivm' +import { Aggregate } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { ExpressionLike } from '../operators/types.js' + +// ============================================================ +// CONFIG +// ============================================================ + +const countConfig = { + factory: groupByOperators.count, + valueTransform: `raw` as const, +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function count(arg: ExpressionLike): Aggregate { + return new Aggregate(`count`, [toExpression(arg)], countConfig) +} diff --git a/packages/db/src/query/builder/aggregates/index.ts b/packages/db/src/query/builder/aggregates/index.ts new file mode 100644 index 000000000..e4fc729bb --- /dev/null +++ b/packages/db/src/query/builder/aggregates/index.ts @@ -0,0 +1,11 @@ +// Re-export all aggregates +// Importing from here will auto-register all aggregate evaluators + +export { sum } from './sum.js' +export { count } from './count.js' +export { avg } from './avg.js' +export { min } from './min.js' +export { max } from './max.js' +export { collect } from './collect.js' +export { minStr } from './minStr.js' +export { maxStr } from './maxStr.js' diff --git a/packages/db/src/query/builder/aggregates/max.ts b/packages/db/src/query/builder/aggregates/max.ts new file mode 100644 index 000000000..3abc4668b --- /dev/null +++ b/packages/db/src/query/builder/aggregates/max.ts @@ -0,0 +1,25 @@ +import { groupByOperators } from '@tanstack/db-ivm' +import { Aggregate } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { AggregateReturnType, ExpressionLike } from '../operators/types.js' + +// ============================================================ +// CONFIG +// ============================================================ + +const maxConfig = { + factory: groupByOperators.max, + valueTransform: `numericOrDate` as const, +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function max(arg: T): AggregateReturnType { + return new Aggregate( + `max`, + [toExpression(arg)], + maxConfig, + ) as AggregateReturnType +} diff --git a/packages/db/src/query/builder/aggregates/maxStr.ts b/packages/db/src/query/builder/aggregates/maxStr.ts new file mode 100644 index 000000000..ff4b2f751 --- /dev/null +++ b/packages/db/src/query/builder/aggregates/maxStr.ts @@ -0,0 +1,42 @@ +import { groupByOperators } from '@tanstack/db-ivm' +import { Aggregate } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { ExpressionLike } from '../operators/types.js' + +// ============================================================ +// CONFIG +// ============================================================ + +const maxStrConfig = { + factory: groupByOperators.max, + valueTransform: `raw` as const, // Preserves string values, no numeric coercion +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +/** + * String-typed max aggregate for lexicographic comparison. + * + * Unlike `max()` which coerces values to numbers, `maxStr()` preserves + * string values for proper lexicographic comparison. This is essential + * for ISO 8601 date strings which sort correctly as strings. + * + * @example + * ```typescript + * // Get the latest timestamp for each group + * query + * .from({ events: eventsCollection }) + * .groupBy(({ events }) => events.userId) + * .select(({ events }) => ({ + * userId: events.userId, + * lastEvent: maxStr(events.createdAt), + * })) + * ``` + */ +export function maxStr( + arg: T, +): Aggregate { + return new Aggregate(`maxStr`, [toExpression(arg)], maxStrConfig) +} diff --git a/packages/db/src/query/builder/aggregates/min.ts b/packages/db/src/query/builder/aggregates/min.ts new file mode 100644 index 000000000..d64302a36 --- /dev/null +++ b/packages/db/src/query/builder/aggregates/min.ts @@ -0,0 +1,25 @@ +import { groupByOperators } from '@tanstack/db-ivm' +import { Aggregate } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { AggregateReturnType, ExpressionLike } from '../operators/types.js' + +// ============================================================ +// CONFIG +// ============================================================ + +const minConfig = { + factory: groupByOperators.min, + valueTransform: `numericOrDate` as const, +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function min(arg: T): AggregateReturnType { + return new Aggregate( + `min`, + [toExpression(arg)], + minConfig, + ) as AggregateReturnType +} diff --git a/packages/db/src/query/builder/aggregates/minStr.ts b/packages/db/src/query/builder/aggregates/minStr.ts new file mode 100644 index 000000000..d62d29ab4 --- /dev/null +++ b/packages/db/src/query/builder/aggregates/minStr.ts @@ -0,0 +1,42 @@ +import { groupByOperators } from '@tanstack/db-ivm' +import { Aggregate } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { ExpressionLike } from '../operators/types.js' + +// ============================================================ +// CONFIG +// ============================================================ + +const minStrConfig = { + factory: groupByOperators.min, + valueTransform: `raw` as const, // Preserves string values, no numeric coercion +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +/** + * String-typed min aggregate for lexicographic comparison. + * + * Unlike `min()` which coerces values to numbers, `minStr()` preserves + * string values for proper lexicographic comparison. This is essential + * for ISO 8601 date strings which sort correctly as strings. + * + * @example + * ```typescript + * // Get the earliest timestamp for each group + * query + * .from({ events: eventsCollection }) + * .groupBy(({ events }) => events.userId) + * .select(({ events }) => ({ + * userId: events.userId, + * firstEvent: minStr(events.createdAt), + * })) + * ``` + */ +export function minStr( + arg: T, +): Aggregate { + return new Aggregate(`minStr`, [toExpression(arg)], minStrConfig) +} diff --git a/packages/db/src/query/builder/aggregates/sum.ts b/packages/db/src/query/builder/aggregates/sum.ts new file mode 100644 index 000000000..030e5b3da --- /dev/null +++ b/packages/db/src/query/builder/aggregates/sum.ts @@ -0,0 +1,25 @@ +import { groupByOperators } from '@tanstack/db-ivm' +import { Aggregate } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { AggregateReturnType, ExpressionLike } from '../operators/types.js' + +// ============================================================ +// CONFIG +// ============================================================ + +const sumConfig = { + factory: groupByOperators.sum, + valueTransform: `numeric` as const, +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function sum(arg: T): AggregateReturnType { + return new Aggregate( + `sum`, + [toExpression(arg)], + sumConfig, + ) as AggregateReturnType +} diff --git a/packages/db/src/query/builder/functions.ts b/packages/db/src/query/builder/functions.ts index 41ce11370..013b478d2 100644 --- a/packages/db/src/query/builder/functions.ts +++ b/packages/db/src/query/builder/functions.ts @@ -1,328 +1,38 @@ -import { Aggregate, Func } from '../ir' -import { toExpression } from './ref-proxy.js' -import type { BasicExpression } from '../ir' -import type { RefProxy } from './ref-proxy.js' -import type { RefLeaf } from './types.js' - -type StringRef = - | RefLeaf - | RefLeaf - | RefLeaf -type StringRefProxy = - | RefProxy - | RefProxy - | RefProxy -type StringBasicExpression = - | BasicExpression - | BasicExpression - | BasicExpression -type StringLike = - | StringRef - | StringRefProxy - | StringBasicExpression - | string - | null - | undefined - -type ComparisonOperand = - | RefProxy - | RefLeaf - | T - | BasicExpression - | undefined - | null -type ComparisonOperandPrimitive = - | T - | BasicExpression - | undefined - | null - -// Helper type for any expression-like value -type ExpressionLike = BasicExpression | RefProxy | RefLeaf | any - -// Helper type to extract the underlying type from various expression types -type ExtractType = - T extends RefProxy - ? U - : T extends RefLeaf - ? U - : T extends BasicExpression - ? U - : T - -// Helper type to determine aggregate return type based on input nullability -type AggregateReturnType = - ExtractType extends infer U - ? U extends number | undefined | null | Date | bigint - ? Aggregate - : Aggregate - : Aggregate - -// Helper type to determine string function return type based on input nullability -type StringFunctionReturnType = - ExtractType extends infer U - ? U extends string | undefined | null - ? BasicExpression - : BasicExpression - : BasicExpression - -// Helper type to determine numeric function return type based on input nullability -// This handles string, array, and number inputs for functions like length() -type NumericFunctionReturnType = - ExtractType extends infer U - ? U extends string | Array | undefined | null | number - ? BasicExpression> - : BasicExpression - : BasicExpression - -// Transform string/array types to number while preserving nullability -type MapToNumber = T extends string | Array - ? number - : T extends undefined - ? undefined - : T extends null - ? 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 - -// Operators - -export function eq( - left: ComparisonOperand, - right: ComparisonOperand, -): BasicExpression -export function eq( - left: ComparisonOperandPrimitive, - right: ComparisonOperandPrimitive, -): BasicExpression -export function eq(left: Aggregate, right: any): BasicExpression -export function eq(left: any, right: any): BasicExpression { - return new Func(`eq`, [toExpression(left), toExpression(right)]) -} - -export function gt( - left: ComparisonOperand, - right: ComparisonOperand, -): BasicExpression -export function gt( - left: ComparisonOperandPrimitive, - right: ComparisonOperandPrimitive, -): BasicExpression -export function gt(left: Aggregate, right: any): BasicExpression -export function gt(left: any, right: any): BasicExpression { - return new Func(`gt`, [toExpression(left), toExpression(right)]) -} - -export function gte( - left: ComparisonOperand, - right: ComparisonOperand, -): BasicExpression -export function gte( - left: ComparisonOperandPrimitive, - right: ComparisonOperandPrimitive, -): BasicExpression -export function gte(left: Aggregate, right: any): BasicExpression -export function gte(left: any, right: any): BasicExpression { - return new Func(`gte`, [toExpression(left), toExpression(right)]) -} - -export function lt( - left: ComparisonOperand, - right: ComparisonOperand, -): BasicExpression -export function lt( - left: ComparisonOperandPrimitive, - right: ComparisonOperandPrimitive, -): BasicExpression -export function lt(left: Aggregate, right: any): BasicExpression -export function lt(left: any, right: any): BasicExpression { - return new Func(`lt`, [toExpression(left), toExpression(right)]) -} - -export function lte( - left: ComparisonOperand, - right: ComparisonOperand, -): BasicExpression -export function lte( - left: ComparisonOperandPrimitive, - right: ComparisonOperandPrimitive, -): BasicExpression -export function lte(left: Aggregate, right: any): BasicExpression -export function lte(left: any, right: any): BasicExpression { - return new Func(`lte`, [toExpression(left), toExpression(right)]) -} - -// Overloads for and() - support 2 or more arguments -export function and( - left: ExpressionLike, - right: ExpressionLike, -): BasicExpression -export function and( - left: ExpressionLike, - right: ExpressionLike, - ...rest: Array -): BasicExpression -export function and( - left: ExpressionLike, - right: ExpressionLike, - ...rest: Array -): BasicExpression { - const allArgs = [left, right, ...rest] - return new Func( - `and`, - allArgs.map((arg) => toExpression(arg)), - ) -} - -// Overloads for or() - support 2 or more arguments -export function or( - left: ExpressionLike, - right: ExpressionLike, -): BasicExpression -export function or( - left: ExpressionLike, - right: ExpressionLike, - ...rest: Array -): BasicExpression -export function or( - left: ExpressionLike, - right: ExpressionLike, - ...rest: Array -): BasicExpression { - const allArgs = [left, right, ...rest] - return new Func( - `or`, - allArgs.map((arg) => toExpression(arg)), - ) -} - -export function not(value: ExpressionLike): BasicExpression { - return new Func(`not`, [toExpression(value)]) -} - -// Null/undefined checking functions -export function isUndefined(value: ExpressionLike): BasicExpression { - return new Func(`isUndefined`, [toExpression(value)]) -} - -export function isNull(value: ExpressionLike): BasicExpression { - return new Func(`isNull`, [toExpression(value)]) -} - -export function inArray( - value: ExpressionLike, - array: ExpressionLike, -): BasicExpression { - return new Func(`in`, [toExpression(value), toExpression(array)]) -} - -export function like( - left: StringLike, - right: StringLike, -): BasicExpression -export function like(left: any, right: any): BasicExpression { - return new Func(`like`, [toExpression(left), toExpression(right)]) -} - -export function ilike( - left: StringLike, - right: StringLike, -): BasicExpression { - return new Func(`ilike`, [toExpression(left), toExpression(right)]) -} - -// Functions - -export function upper( - arg: T, -): StringFunctionReturnType { - return new Func(`upper`, [toExpression(arg)]) as StringFunctionReturnType -} - -export function lower( - arg: T, -): StringFunctionReturnType { - return new Func(`lower`, [toExpression(arg)]) as StringFunctionReturnType -} - -export function length( - arg: T, -): NumericFunctionReturnType { - return new Func(`length`, [toExpression(arg)]) as NumericFunctionReturnType -} - -export function concat( - ...args: Array -): BasicExpression { - return new Func( - `concat`, - args.map((arg) => toExpression(arg)), - ) -} - -export function coalesce(...args: Array): BasicExpression { - return new Func( - `coalesce`, - args.map((arg) => toExpression(arg)), - ) -} - -export function add( - left: T1, - right: T2, -): BinaryNumericReturnType { - return new Func(`add`, [ - toExpression(left), - toExpression(right), - ]) as BinaryNumericReturnType -} - -// Aggregates - -export function count(arg: ExpressionLike): Aggregate { - return new Aggregate(`count`, [toExpression(arg)]) -} - -export function avg(arg: T): AggregateReturnType { - return new Aggregate(`avg`, [toExpression(arg)]) as AggregateReturnType -} - -export function sum(arg: T): AggregateReturnType { - return new Aggregate(`sum`, [toExpression(arg)]) as AggregateReturnType -} - -export function min(arg: T): AggregateReturnType { - return new Aggregate(`min`, [toExpression(arg)]) as AggregateReturnType -} - -export function max(arg: T): AggregateReturnType { - return new Aggregate(`max`, [toExpression(arg)]) as AggregateReturnType -} +// Re-export all operators from their individual modules +// Each module auto-registers its evaluator when imported +export { eq } from './operators/eq.js' +export { gt } from './operators/gt.js' +export { gte } from './operators/gte.js' +export { lt } from './operators/lt.js' +export { lte } from './operators/lte.js' +export { and } from './operators/and.js' +export { or } from './operators/or.js' +export { not } from './operators/not.js' +export { inArray } from './operators/in.js' +export { like } from './operators/like.js' +export { ilike } from './operators/ilike.js' +export { upper } from './operators/upper.js' +export { lower } from './operators/lower.js' +export { length } from './operators/length.js' +export { concat } from './operators/concat.js' +export { coalesce } from './operators/coalesce.js' +export { add } from './operators/add.js' +export { subtract } from './operators/subtract.js' +export { multiply } from './operators/multiply.js' +export { divide } from './operators/divide.js' +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' +export { min } from './aggregates/min.js' +export { max } from './aggregates/max.js' +export { collect } from './aggregates/collect.js' +export { minStr } from './aggregates/minStr.js' +export { maxStr } from './aggregates/maxStr.js' /** * List of comparison function names that can be used with indexes @@ -365,6 +75,9 @@ export const operators = [ `concat`, // Numeric functions `add`, + `subtract`, + `multiply`, + `divide`, // Utility functions `coalesce`, // Aggregate functions @@ -373,6 +86,9 @@ export const operators = [ `sum`, `min`, `max`, + `collect`, + `minStr`, + `maxStr`, ] as const export type OperatorName = (typeof operators)[number] diff --git a/packages/db/src/query/builder/operators/add.ts b/packages/db/src/query/builder/operators/add.ts new file mode 100644 index 000000000..a6e7fe631 --- /dev/null +++ b/packages/db/src/query/builder/operators/add.ts @@ -0,0 +1,37 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { CompiledExpression } from '../../ir.js' +import type { BinaryNumericReturnType, ExpressionLike } from './types.js' + +// ============================================================ +// EVALUATOR +// ============================================================ + +function addEvaluatorFactory( + compiledArgs: Array, + _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 +// ============================================================ + +export function add( + left: T1, + right: T2, +): BinaryNumericReturnType { + return new Func( + `add`, + [toExpression(left), toExpression(right)], + addEvaluatorFactory, + ) as BinaryNumericReturnType +} diff --git a/packages/db/src/query/builder/operators/and.ts b/packages/db/src/query/builder/operators/and.ts new file mode 100644 index 000000000..525cb547a --- /dev/null +++ b/packages/db/src/query/builder/operators/and.ts @@ -0,0 +1,86 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { BasicExpression, CompiledExpression } from '../../ir.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, + _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 +// ============================================================ + +// Overloads for and() - support 2 or more arguments, or an array +export function and( + left: ExpressionLike, + right: ExpressionLike, +): BasicExpression +export function and( + left: ExpressionLike, + right: ExpressionLike, + ...rest: Array +): BasicExpression +export function and(args: Array): BasicExpression +export function and( + leftOrArgs: ExpressionLike | Array, + right?: ExpressionLike, + ...rest: Array +): BasicExpression { + // Handle array overload + if (Array.isArray(leftOrArgs) && right === undefined) { + return new Func( + `and`, + leftOrArgs.map((arg) => toExpression(arg)), + andEvaluatorFactory, + ) + } + // Handle variadic overload + const allArgs = [leftOrArgs, right!, ...rest] + return new Func( + `and`, + allArgs.map((arg) => toExpression(arg)), + andEvaluatorFactory, + ) +} diff --git a/packages/db/src/query/builder/operators/coalesce.ts b/packages/db/src/query/builder/operators/coalesce.ts new file mode 100644 index 000000000..6bc9642e6 --- /dev/null +++ b/packages/db/src/query/builder/operators/coalesce.ts @@ -0,0 +1,41 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { BasicExpression, CompiledExpression } from '../../ir.js' + +// ============================================================ +// TYPES +// ============================================================ + +// Helper type for any expression-like value +type ExpressionLike = BasicExpression | any + +// ============================================================ +// EVALUATOR +// ============================================================ + +function coalesceEvaluatorFactory( + compiledArgs: Array, + _isSingleRow: boolean, +): CompiledExpression { + return (data: any) => { + for (const evaluator of compiledArgs) { + const value = evaluator(data) + if (value !== null && value !== undefined) { + return value + } + } + return null + } +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function coalesce(...args: Array): BasicExpression { + return new Func( + `coalesce`, + args.map((arg) => toExpression(arg)), + coalesceEvaluatorFactory, + ) +} diff --git a/packages/db/src/query/builder/operators/concat.ts b/packages/db/src/query/builder/operators/concat.ts new file mode 100644 index 000000000..f05ff9dc8 --- /dev/null +++ b/packages/db/src/query/builder/operators/concat.ts @@ -0,0 +1,50 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { BasicExpression, CompiledExpression } from '../../ir.js' + +// ============================================================ +// TYPES +// ============================================================ + +// Helper type for any expression-like value +type ExpressionLike = BasicExpression | any + +// ============================================================ +// EVALUATOR +// ============================================================ + +function concatEvaluatorFactory( + compiledArgs: Array, + _isSingleRow: boolean, +): CompiledExpression { + return (data: any) => { + return compiledArgs + .map((evaluator) => { + const arg = evaluator(data) + try { + return String(arg ?? ``) + } catch { + try { + return JSON.stringify(arg) || `` + } catch { + return `[object]` + } + } + }) + .join(``) + } +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function concat( + ...args: Array +): BasicExpression { + return new Func( + `concat`, + args.map((arg) => toExpression(arg)), + concatEvaluatorFactory, + ) +} diff --git a/packages/db/src/query/builder/operators/divide.ts b/packages/db/src/query/builder/operators/divide.ts new file mode 100644 index 000000000..8e91163cc --- /dev/null +++ b/packages/db/src/query/builder/operators/divide.ts @@ -0,0 +1,49 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { BasicExpression, CompiledExpression } from '../../ir.js' + +// ============================================================ +// TYPES +// ============================================================ + +// Helper type for any expression-like value +type ExpressionLike = BasicExpression | any + +// Helper type for binary numeric operations (combines nullability of both operands) +type BinaryNumericReturnType<_T1, _T2> = BasicExpression< + number | undefined | null +> + +// ============================================================ +// EVALUATOR +// ============================================================ + +function divideEvaluatorFactory( + compiledArgs: Array, + _isSingleRow: boolean, +): CompiledExpression { + const argA = compiledArgs[0]! + const argB = compiledArgs[1]! + + return (data: any) => { + const a = argA(data) + const b = argB(data) + const divisor = b ?? 0 + return divisor !== 0 ? (a ?? 0) / divisor : null + } +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function divide( + left: T1, + right: T2, +): BinaryNumericReturnType { + return new Func( + `divide`, + [toExpression(left), toExpression(right)], + divideEvaluatorFactory, + ) as BinaryNumericReturnType +} diff --git a/packages/db/src/query/builder/operators/eq.ts b/packages/db/src/query/builder/operators/eq.ts new file mode 100644 index 000000000..56cf19cdc --- /dev/null +++ b/packages/db/src/query/builder/operators/eq.ts @@ -0,0 +1,77 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import { areValuesEqual, normalizeValue } from '../../../utils/comparison.js' +import type { + Aggregate, + BasicExpression, + CompiledExpression, +} from '../../ir.js' +import type { RefProxy } from '../ref-proxy.js' +import type { RefLeaf } from '../types.js' + +// ============================================================ +// TYPES +// ============================================================ + +type ComparisonOperand = + | RefProxy + | RefLeaf + | T + | BasicExpression + | undefined + | null + +type ComparisonOperandPrimitive = + | T + | BasicExpression + | undefined + | null + +// ============================================================ +// EVALUATOR FACTORY +// ============================================================ + +function isUnknown(value: any): boolean { + return value === null || value === undefined +} + +function eqEvaluatorFactory( + compiledArgs: Array, + _isSingleRow: boolean, +): CompiledExpression { + const argA = compiledArgs[0]! + const argB = compiledArgs[1]! + + return (data: any) => { + const a = normalizeValue(argA(data)) + const b = normalizeValue(argB(data)) + + // 3-valued logic: comparison with null/undefined returns UNKNOWN + if (isUnknown(a) || isUnknown(b)) { + return null + } + + return areValuesEqual(a, b) + } +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function eq( + left: ComparisonOperand, + right: ComparisonOperand, +): BasicExpression +export function eq( + left: ComparisonOperandPrimitive, + right: ComparisonOperandPrimitive, +): BasicExpression +export function eq(left: Aggregate, right: any): BasicExpression +export function eq(left: any, right: any): BasicExpression { + return new Func( + `eq`, + [toExpression(left), toExpression(right)], + eqEvaluatorFactory, + ) +} diff --git a/packages/db/src/query/builder/operators/gt.ts b/packages/db/src/query/builder/operators/gt.ts new file mode 100644 index 000000000..94238c811 --- /dev/null +++ b/packages/db/src/query/builder/operators/gt.ts @@ -0,0 +1,76 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { + Aggregate, + BasicExpression, + CompiledExpression, +} from '../../ir.js' +import type { RefProxy } from '../ref-proxy.js' +import type { RefLeaf } from '../types.js' + +// ============================================================ +// TYPES +// ============================================================ + +type ComparisonOperand = + | RefProxy + | RefLeaf + | T + | BasicExpression + | undefined + | null + +type ComparisonOperandPrimitive = + | T + | BasicExpression + | undefined + | null + +// ============================================================ +// EVALUATOR +// ============================================================ + +function isUnknown(value: any): boolean { + return value === null || value === undefined +} + +function gtEvaluatorFactory( + compiledArgs: Array, + _isSingleRow: boolean, +): CompiledExpression { + const argA = compiledArgs[0]! + const argB = compiledArgs[1]! + + return (data: any) => { + const a = argA(data) + const b = argB(data) + + // In 3-valued logic, any comparison with null/undefined returns UNKNOWN + if (isUnknown(a) || isUnknown(b)) { + return null + } + + return a > b + } +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function gt( + left: ComparisonOperand, + right: ComparisonOperand, +): BasicExpression +export function gt( + left: ComparisonOperandPrimitive, + right: ComparisonOperandPrimitive, +): BasicExpression +export function gt(left: Aggregate, right: any): BasicExpression +export function gt(left: any, right: any): BasicExpression { + return new Func( + `gt`, + [toExpression(left), toExpression(right)], + gtEvaluatorFactory, + ) +} diff --git a/packages/db/src/query/builder/operators/gte.ts b/packages/db/src/query/builder/operators/gte.ts new file mode 100644 index 000000000..f35ecc9f5 --- /dev/null +++ b/packages/db/src/query/builder/operators/gte.ts @@ -0,0 +1,76 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { + Aggregate, + BasicExpression, + CompiledExpression, +} from '../../ir.js' +import type { RefProxy } from '../ref-proxy.js' +import type { RefLeaf } from '../types.js' + +// ============================================================ +// TYPES +// ============================================================ + +type ComparisonOperand = + | RefProxy + | RefLeaf + | T + | BasicExpression + | undefined + | null + +type ComparisonOperandPrimitive = + | T + | BasicExpression + | undefined + | null + +// ============================================================ +// EVALUATOR +// ============================================================ + +function isUnknown(value: any): boolean { + return value === null || value === undefined +} + +function gteEvaluatorFactory( + compiledArgs: Array, + _isSingleRow: boolean, +): CompiledExpression { + const argA = compiledArgs[0]! + const argB = compiledArgs[1]! + + return (data: any) => { + const a = argA(data) + const b = argB(data) + + // In 3-valued logic, any comparison with null/undefined returns UNKNOWN + if (isUnknown(a) || isUnknown(b)) { + return null + } + + return a >= b + } +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function gte( + left: ComparisonOperand, + right: ComparisonOperand, +): BasicExpression +export function gte( + left: ComparisonOperandPrimitive, + right: ComparisonOperandPrimitive, +): BasicExpression +export function gte(left: Aggregate, right: any): BasicExpression +export function gte(left: any, right: any): BasicExpression { + return new Func( + `gte`, + [toExpression(left), toExpression(right)], + gteEvaluatorFactory, + ) +} diff --git a/packages/db/src/query/builder/operators/ilike.ts b/packages/db/src/query/builder/operators/ilike.ts new file mode 100644 index 000000000..d880e822b --- /dev/null +++ b/packages/db/src/query/builder/operators/ilike.ts @@ -0,0 +1,55 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import { evaluateLike } from './like.js' +import type { BasicExpression, CompiledExpression } from '../../ir.js' + +// ============================================================ +// TYPES +// ============================================================ + +type StringRef = + | BasicExpression + | BasicExpression + | BasicExpression +type StringLike = StringRef | string | null | undefined | any + +// ============================================================ +// EVALUATOR +// ============================================================ + +function isUnknown(value: any): boolean { + return value === null || value === undefined +} + +function ilikeEvaluatorFactory( + compiledArgs: Array, + _isSingleRow: boolean, +): CompiledExpression { + const valueEvaluator = compiledArgs[0]! + const patternEvaluator = compiledArgs[1]! + + return (data: any) => { + const value = valueEvaluator(data) + const pattern = patternEvaluator(data) + // In 3-valued logic, if value or pattern is null/undefined, return UNKNOWN + if (isUnknown(value) || isUnknown(pattern)) { + return null + } + return evaluateLike(value, pattern, true) + } +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function ilike( + left: StringLike, + right: StringLike, +): BasicExpression { + return new Func( + `ilike`, + [toExpression(left), toExpression(right)], + ilikeEvaluatorFactory, + ) +} diff --git a/packages/db/src/query/builder/operators/in.ts b/packages/db/src/query/builder/operators/in.ts new file mode 100644 index 000000000..7027365d6 --- /dev/null +++ b/packages/db/src/query/builder/operators/in.ts @@ -0,0 +1,54 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { BasicExpression, CompiledExpression } from '../../ir.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 inEvaluatorFactory( + compiledArgs: Array, + _isSingleRow: boolean, +): CompiledExpression { + const valueEvaluator = compiledArgs[0]! + const arrayEvaluator = compiledArgs[1]! + + return (data: any) => { + const value = valueEvaluator(data) + const array = arrayEvaluator(data) + // In 3-valued logic, if the value is null/undefined, return UNKNOWN + if (isUnknown(value)) { + return null + } + if (!Array.isArray(array)) { + return false + } + return array.includes(value) + } +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function inArray( + value: ExpressionLike, + array: ExpressionLike, +): BasicExpression { + return new Func( + `in`, + [toExpression(value), toExpression(array)], + inEvaluatorFactory, + ) +} diff --git a/packages/db/src/query/builder/operators/index.ts b/packages/db/src/query/builder/operators/index.ts new file mode 100644 index 000000000..6f463741e --- /dev/null +++ b/packages/db/src/query/builder/operators/index.ts @@ -0,0 +1,38 @@ +// Re-export all operators +// Importing from here will auto-register all evaluators + +// Comparison operators +export { eq } from './eq.js' +export { gt } from './gt.js' +export { gte } from './gte.js' +export { lt } from './lt.js' +export { lte } from './lte.js' + +// Boolean operators +export { and } from './and.js' +export { or } from './or.js' +export { not } from './not.js' + +// Array operators +export { inArray } from './in.js' + +// String pattern operators +export { like } from './like.js' +export { ilike } from './ilike.js' + +// String functions +export { upper } from './upper.js' +export { lower } from './lower.js' +export { length } from './length.js' +export { concat } from './concat.js' +export { coalesce } from './coalesce.js' + +// Math functions +export { add } from './add.js' +export { subtract } from './subtract.js' +export { multiply } from './multiply.js' +export { divide } from './divide.js' + +// Null checking functions +export { isNull } from './isNull.js' +export { isUndefined } from './isUndefined.js' diff --git a/packages/db/src/query/builder/operators/isNull.ts b/packages/db/src/query/builder/operators/isNull.ts new file mode 100644 index 000000000..a7860b5d1 --- /dev/null +++ b/packages/db/src/query/builder/operators/isNull.ts @@ -0,0 +1,34 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { BasicExpression, CompiledExpression } from '../../ir.js' + +// ============================================================ +// TYPES +// ============================================================ + +// Helper type for any expression-like value +type ExpressionLike = BasicExpression | any + +// ============================================================ +// EVALUATOR +// ============================================================ + +function isNullEvaluatorFactory( + compiledArgs: Array, + _isSingleRow: boolean, +): CompiledExpression { + const arg = compiledArgs[0]! + + return (data: any) => { + const value = arg(data) + return value === null + } +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function isNull(value: ExpressionLike): BasicExpression { + return new Func(`isNull`, [toExpression(value)], isNullEvaluatorFactory) +} diff --git a/packages/db/src/query/builder/operators/isUndefined.ts b/packages/db/src/query/builder/operators/isUndefined.ts new file mode 100644 index 000000000..743bb84bd --- /dev/null +++ b/packages/db/src/query/builder/operators/isUndefined.ts @@ -0,0 +1,38 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { BasicExpression, CompiledExpression } from '../../ir.js' + +// ============================================================ +// TYPES +// ============================================================ + +// Helper type for any expression-like value +type ExpressionLike = BasicExpression | any + +// ============================================================ +// EVALUATOR +// ============================================================ + +function isUndefinedEvaluatorFactory( + compiledArgs: Array, + _isSingleRow: boolean, +): CompiledExpression { + const arg = compiledArgs[0]! + + return (data: any) => { + const value = arg(data) + return value === undefined + } +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function isUndefined(value: ExpressionLike): BasicExpression { + return new Func( + `isUndefined`, + [toExpression(value)], + isUndefinedEvaluatorFactory, + ) +} diff --git a/packages/db/src/query/builder/operators/length.ts b/packages/db/src/query/builder/operators/length.ts new file mode 100644 index 000000000..1b4188d66 --- /dev/null +++ b/packages/db/src/query/builder/operators/length.ts @@ -0,0 +1,40 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { CompiledExpression } from '../../ir.js' +import type { ExpressionLike, NumericFunctionReturnType } from './types.js' + +// ============================================================ +// EVALUATOR +// ============================================================ + +function lengthEvaluatorFactory( + compiledArgs: Array, + _isSingleRow: boolean, +): CompiledExpression { + const arg = compiledArgs[0]! + + return (data: any) => { + const value = arg(data) + if (typeof value === `string`) { + return value.length + } + if (Array.isArray(value)) { + return value.length + } + return 0 + } +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function length( + arg: T, +): NumericFunctionReturnType { + return new Func( + `length`, + [toExpression(arg)], + lengthEvaluatorFactory, + ) as NumericFunctionReturnType +} diff --git a/packages/db/src/query/builder/operators/like.ts b/packages/db/src/query/builder/operators/like.ts new file mode 100644 index 000000000..eac0ca042 --- /dev/null +++ b/packages/db/src/query/builder/operators/like.ts @@ -0,0 +1,85 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { BasicExpression, CompiledExpression } from '../../ir.js' + +// ============================================================ +// TYPES +// ============================================================ + +type StringRef = + | BasicExpression + | BasicExpression + | BasicExpression +type StringLike = StringRef | string | null | undefined | any + +// ============================================================ +// EVALUATOR +// ============================================================ + +function isUnknown(value: any): boolean { + return value === null || value === undefined +} + +/** + * Evaluates LIKE patterns + */ +function evaluateLike( + value: any, + pattern: any, + caseInsensitive: boolean, +): boolean { + if (typeof value !== `string` || typeof pattern !== `string`) { + return false + } + + const searchValue = caseInsensitive ? value.toLowerCase() : value + const searchPattern = caseInsensitive ? pattern.toLowerCase() : pattern + + // Convert SQL LIKE pattern to regex + // First escape all regex special chars except % and _ + let regexPattern = searchPattern.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`) + + // Then convert SQL wildcards to regex + regexPattern = regexPattern.replace(/%/g, `.*`) // % matches any sequence + regexPattern = regexPattern.replace(/_/g, `.`) // _ matches any single char + + const regex = new RegExp(`^${regexPattern}$`) + return regex.test(searchValue) +} + +function likeEvaluatorFactory( + compiledArgs: Array, + _isSingleRow: boolean, +): CompiledExpression { + const valueEvaluator = compiledArgs[0]! + const patternEvaluator = compiledArgs[1]! + + return (data: any) => { + const value = valueEvaluator(data) + const pattern = patternEvaluator(data) + // In 3-valued logic, if value or pattern is null/undefined, return UNKNOWN + if (isUnknown(value) || isUnknown(pattern)) { + return null + } + return evaluateLike(value, pattern, false) + } +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function like( + left: StringLike, + right: StringLike, +): BasicExpression +export function like(left: any, right: any): BasicExpression { + return new Func( + `like`, + [toExpression(left), toExpression(right)], + likeEvaluatorFactory, + ) +} + +// Export for use by ilike +export { evaluateLike } diff --git a/packages/db/src/query/builder/operators/lower.ts b/packages/db/src/query/builder/operators/lower.ts new file mode 100644 index 000000000..1cffb3683 --- /dev/null +++ b/packages/db/src/query/builder/operators/lower.ts @@ -0,0 +1,34 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { CompiledExpression } from '../../ir.js' +import type { ExpressionLike, StringFunctionReturnType } from './types.js' + +// ============================================================ +// EVALUATOR +// ============================================================ + +function lowerEvaluatorFactory( + compiledArgs: Array, + _isSingleRow: boolean, +): CompiledExpression { + const arg = compiledArgs[0]! + + return (data: any) => { + const value = arg(data) + return typeof value === `string` ? value.toLowerCase() : value + } +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function lower( + arg: T, +): StringFunctionReturnType { + return new Func( + `lower`, + [toExpression(arg)], + lowerEvaluatorFactory, + ) as StringFunctionReturnType +} diff --git a/packages/db/src/query/builder/operators/lt.ts b/packages/db/src/query/builder/operators/lt.ts new file mode 100644 index 000000000..89c31bd99 --- /dev/null +++ b/packages/db/src/query/builder/operators/lt.ts @@ -0,0 +1,76 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { + Aggregate, + BasicExpression, + CompiledExpression, +} from '../../ir.js' +import type { RefProxy } from '../ref-proxy.js' +import type { RefLeaf } from '../types.js' + +// ============================================================ +// TYPES +// ============================================================ + +type ComparisonOperand = + | RefProxy + | RefLeaf + | T + | BasicExpression + | undefined + | null + +type ComparisonOperandPrimitive = + | T + | BasicExpression + | undefined + | null + +// ============================================================ +// EVALUATOR +// ============================================================ + +function isUnknown(value: any): boolean { + return value === null || value === undefined +} + +function ltEvaluatorFactory( + compiledArgs: Array, + _isSingleRow: boolean, +): CompiledExpression { + const argA = compiledArgs[0]! + const argB = compiledArgs[1]! + + return (data: any) => { + const a = argA(data) + const b = argB(data) + + // In 3-valued logic, any comparison with null/undefined returns UNKNOWN + if (isUnknown(a) || isUnknown(b)) { + return null + } + + return a < b + } +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function lt( + left: ComparisonOperand, + right: ComparisonOperand, +): BasicExpression +export function lt( + left: ComparisonOperandPrimitive, + right: ComparisonOperandPrimitive, +): BasicExpression +export function lt(left: Aggregate, right: any): BasicExpression +export function lt(left: any, right: any): BasicExpression { + return new Func( + `lt`, + [toExpression(left), toExpression(right)], + ltEvaluatorFactory, + ) +} diff --git a/packages/db/src/query/builder/operators/lte.ts b/packages/db/src/query/builder/operators/lte.ts new file mode 100644 index 000000000..1ba792d74 --- /dev/null +++ b/packages/db/src/query/builder/operators/lte.ts @@ -0,0 +1,76 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { + Aggregate, + BasicExpression, + CompiledExpression, +} from '../../ir.js' +import type { RefProxy } from '../ref-proxy.js' +import type { RefLeaf } from '../types.js' + +// ============================================================ +// TYPES +// ============================================================ + +type ComparisonOperand = + | RefProxy + | RefLeaf + | T + | BasicExpression + | undefined + | null + +type ComparisonOperandPrimitive = + | T + | BasicExpression + | undefined + | null + +// ============================================================ +// EVALUATOR +// ============================================================ + +function isUnknown(value: any): boolean { + return value === null || value === undefined +} + +function lteEvaluatorFactory( + compiledArgs: Array, + _isSingleRow: boolean, +): CompiledExpression { + const argA = compiledArgs[0]! + const argB = compiledArgs[1]! + + return (data: any) => { + const a = argA(data) + const b = argB(data) + + // In 3-valued logic, any comparison with null/undefined returns UNKNOWN + if (isUnknown(a) || isUnknown(b)) { + return null + } + + return a <= b + } +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function lte( + left: ComparisonOperand, + right: ComparisonOperand, +): BasicExpression +export function lte( + left: ComparisonOperandPrimitive, + right: ComparisonOperandPrimitive, +): BasicExpression +export function lte(left: Aggregate, right: any): BasicExpression +export function lte(left: any, right: any): BasicExpression { + return new Func( + `lte`, + [toExpression(left), toExpression(right)], + lteEvaluatorFactory, + ) +} diff --git a/packages/db/src/query/builder/operators/multiply.ts b/packages/db/src/query/builder/operators/multiply.ts new file mode 100644 index 000000000..95e328474 --- /dev/null +++ b/packages/db/src/query/builder/operators/multiply.ts @@ -0,0 +1,48 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { BasicExpression, CompiledExpression } from '../../ir.js' + +// ============================================================ +// TYPES +// ============================================================ + +// Helper type for any expression-like value +type ExpressionLike = BasicExpression | any + +// Helper type for binary numeric operations (combines nullability of both operands) +type BinaryNumericReturnType<_T1, _T2> = BasicExpression< + number | undefined | null +> + +// ============================================================ +// EVALUATOR +// ============================================================ + +function multiplyEvaluatorFactory( + compiledArgs: Array, + _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 +// ============================================================ + +export function multiply( + left: T1, + right: T2, +): BinaryNumericReturnType { + return new Func( + `multiply`, + [toExpression(left), toExpression(right)], + multiplyEvaluatorFactory, + ) as BinaryNumericReturnType +} diff --git a/packages/db/src/query/builder/operators/not.ts b/packages/db/src/query/builder/operators/not.ts new file mode 100644 index 000000000..9a59f9461 --- /dev/null +++ b/packages/db/src/query/builder/operators/not.ts @@ -0,0 +1,45 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { BasicExpression, CompiledExpression } from '../../ir.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 notEvaluatorFactory( + compiledArgs: Array, + _isSingleRow: boolean, +): CompiledExpression { + const arg = compiledArgs[0]! + + return (data: any) => { + // 3-valued logic for NOT: + // - NOT null = null + // - NOT true = false + // - NOT false = true + const result = arg(data) + if (isUnknown(result)) { + return null + } + return !result + } +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function not(value: ExpressionLike): BasicExpression { + return new Func(`not`, [toExpression(value)], notEvaluatorFactory) +} diff --git a/packages/db/src/query/builder/operators/or.ts b/packages/db/src/query/builder/operators/or.ts new file mode 100644 index 000000000..e7dd4f7b1 --- /dev/null +++ b/packages/db/src/query/builder/operators/or.ts @@ -0,0 +1,84 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { BasicExpression, CompiledExpression } from '../../ir.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 orEvaluatorFactory( + compiledArgs: Array, + _isSingleRow: boolean, +): CompiledExpression { + return (data: any) => { + // 3-valued logic for OR: + // - true OR anything = true (short-circuit) + // - null OR anything (except true) = null + // - false OR false = false + let hasUnknown = false + for (const compiledArg of compiledArgs) { + const result = compiledArg(data) + if (result === true) { + return true + } + if (isUnknown(result)) { + hasUnknown = true + } + } + // If we got here, no operand was true + // If any operand was null, return null (UNKNOWN) + if (hasUnknown) { + return null + } + + return false + } +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +// Overloads for or() - support 2 or more arguments, or an array +export function or( + left: ExpressionLike, + right: ExpressionLike, +): BasicExpression +export function or( + left: ExpressionLike, + right: ExpressionLike, + ...rest: Array +): BasicExpression +export function or(args: Array): BasicExpression +export function or( + leftOrArgs: ExpressionLike | Array, + right?: ExpressionLike, + ...rest: Array +): BasicExpression { + // Handle array overload + if (Array.isArray(leftOrArgs) && right === undefined) { + return new Func( + `or`, + leftOrArgs.map((arg) => toExpression(arg)), + orEvaluatorFactory, + ) + } + // Handle variadic overload + const allArgs = [leftOrArgs, right!, ...rest] + return new Func( + `or`, + allArgs.map((arg) => toExpression(arg)), + orEvaluatorFactory, + ) +} diff --git a/packages/db/src/query/builder/operators/subtract.ts b/packages/db/src/query/builder/operators/subtract.ts new file mode 100644 index 000000000..ec15e8b7c --- /dev/null +++ b/packages/db/src/query/builder/operators/subtract.ts @@ -0,0 +1,48 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { BasicExpression, CompiledExpression } from '../../ir.js' + +// ============================================================ +// TYPES +// ============================================================ + +// Helper type for any expression-like value +type ExpressionLike = BasicExpression | any + +// Helper type for binary numeric operations (combines nullability of both operands) +type BinaryNumericReturnType<_T1, _T2> = BasicExpression< + number | undefined | null +> + +// ============================================================ +// EVALUATOR +// ============================================================ + +function subtractEvaluatorFactory( + compiledArgs: Array, + _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 +// ============================================================ + +export function subtract( + left: T1, + right: T2, +): BinaryNumericReturnType { + return new Func( + `subtract`, + [toExpression(left), toExpression(right)], + subtractEvaluatorFactory, + ) as BinaryNumericReturnType +} diff --git a/packages/db/src/query/builder/operators/types.ts b/packages/db/src/query/builder/operators/types.ts new file mode 100644 index 000000000..5f3d94aad --- /dev/null +++ b/packages/db/src/query/builder/operators/types.ts @@ -0,0 +1,120 @@ +/** + * Shared types for operator modules + * These helper types preserve nullability information in return types + */ + +import type { Aggregate, BasicExpression } from '../../ir.js' +import type { RefProxy } from '../ref-proxy.js' +import type { RefLeaf } from '../types.js' + +// String-like types +type StringRef = + | RefLeaf + | RefLeaf + | RefLeaf +type StringRefProxy = + | RefProxy + | RefProxy + | RefProxy +type StringBasicExpression = + | BasicExpression + | BasicExpression + | BasicExpression +export type StringLike = + | StringRef + | StringRefProxy + | StringBasicExpression + | string + | null + | undefined + +// Comparison operand types +export type ComparisonOperand = + | RefProxy + | RefLeaf + | T + | BasicExpression + | undefined + | null +export type ComparisonOperandPrimitive = + | T + | BasicExpression + | undefined + | null + +// Helper type for any expression-like value +export type ExpressionLike = + | BasicExpression + | RefProxy + | RefLeaf + | any + +// Helper type to extract the underlying type from various expression types +export type ExtractType = + T extends RefProxy + ? U + : T extends RefLeaf + ? U + : T extends BasicExpression + ? U + : T + +// Helper type to determine aggregate return type based on input nullability +export type AggregateReturnType = + ExtractType extends infer U + ? U extends number | undefined | null | Date | bigint + ? Aggregate + : Aggregate + : Aggregate + +// Helper type to determine string function return type based on input nullability +export type StringFunctionReturnType = + ExtractType extends infer U + ? U extends string | undefined | null + ? BasicExpression + : BasicExpression + : BasicExpression + +// Helper type to determine numeric function return type based on input nullability +// This handles string, array, and number inputs for functions like length() +export type NumericFunctionReturnType = + ExtractType extends infer U + ? U extends string | Array | undefined | null | number + ? BasicExpression> + : BasicExpression + : BasicExpression + +// Transform string/array types to number while preserving nullability +type MapToNumber = T extends string | Array + ? number + : T extends undefined + ? undefined + : T extends null + ? null + : T + +// Helper type for binary numeric operations (combines nullability of both operands) +export 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 diff --git a/packages/db/src/query/builder/operators/upper.ts b/packages/db/src/query/builder/operators/upper.ts new file mode 100644 index 000000000..e72e4d470 --- /dev/null +++ b/packages/db/src/query/builder/operators/upper.ts @@ -0,0 +1,34 @@ +import { Func } from '../../ir.js' +import { toExpression } from '../ref-proxy.js' +import type { CompiledExpression } from '../../ir.js' +import type { ExpressionLike, StringFunctionReturnType } from './types.js' + +// ============================================================ +// EVALUATOR +// ============================================================ + +function upperEvaluatorFactory( + compiledArgs: Array, + _isSingleRow: boolean, +): CompiledExpression { + const arg = compiledArgs[0]! + + return (data: any) => { + const value = arg(data) + return typeof value === `string` ? value.toUpperCase() : value + } +} + +// ============================================================ +// BUILDER FUNCTION +// ============================================================ + +export function upper( + arg: T, +): StringFunctionReturnType { + return new Func( + `upper`, + [toExpression(arg)], + upperEvaluatorFactory, + ) as StringFunctionReturnType +} diff --git a/packages/db/src/query/compiler/evaluators.ts b/packages/db/src/query/compiler/evaluators.ts index 5e44e5bcd..796bb1b77 100644 --- a/packages/db/src/query/compiler/evaluators.ts +++ b/packages/db/src/query/compiler/evaluators.ts @@ -3,16 +3,11 @@ import { UnknownExpressionTypeError, UnknownFunctionError, } from '../../errors.js' -import { areValuesEqual, normalizeValue } from '../../utils/comparison.js' import type { BasicExpression, Func, PropRef } from '../ir.js' import type { NamespacedRow } from '../../types.js' -/** - * Helper function to check if a value is null or undefined (represents UNKNOWN in 3-valued logic) - */ -function isUnknown(value: any): boolean { - return value === null || value === undefined -} +// Each operator's Func node carries its own factory function. +// No global registry is needed - the factory is passed directly to Func. /** * Converts a 3-valued logic result to a boolean for use in WHERE/HAVING filters. @@ -64,8 +59,9 @@ export function compileSingleRowExpression( /** * Internal unified expression compiler that handles both namespaced and single-row evaluation + * Exported for use by operator modules that need to compile their arguments. */ -function compileExpressionInternal( +export function compileExpressionInternal( expr: BasicExpression, isSingleRow: boolean, ): (data: any) => any { @@ -196,326 +192,11 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any { compileExpressionInternal(arg, isSingleRow), ) - switch (func.name) { - // Comparison operators - case `eq`: { - const argA = compiledArgs[0]! - const argB = compiledArgs[1]! - return (data) => { - const a = normalizeValue(argA(data)) - const b = normalizeValue(argB(data)) - // In 3-valued logic, any comparison with null/undefined returns UNKNOWN - if (isUnknown(a) || isUnknown(b)) { - return null - } - // Use areValuesEqual for proper Uint8Array/Buffer comparison - return areValuesEqual(a, b) - } - } - case `gt`: { - const argA = compiledArgs[0]! - const argB = compiledArgs[1]! - return (data) => { - const a = argA(data) - const b = argB(data) - // In 3-valued logic, any comparison with null/undefined returns UNKNOWN - if (isUnknown(a) || isUnknown(b)) { - return null - } - return a > b - } - } - case `gte`: { - const argA = compiledArgs[0]! - const argB = compiledArgs[1]! - return (data) => { - const a = argA(data) - const b = argB(data) - // In 3-valued logic, any comparison with null/undefined returns UNKNOWN - if (isUnknown(a) || isUnknown(b)) { - return null - } - return a >= b - } - } - case `lt`: { - const argA = compiledArgs[0]! - const argB = compiledArgs[1]! - return (data) => { - const a = argA(data) - const b = argB(data) - // In 3-valued logic, any comparison with null/undefined returns UNKNOWN - if (isUnknown(a) || isUnknown(b)) { - return null - } - return a < b - } - } - case `lte`: { - const argA = compiledArgs[0]! - const argB = compiledArgs[1]! - return (data) => { - const a = argA(data) - const b = argB(data) - // In 3-valued logic, any comparison with null/undefined returns UNKNOWN - if (isUnknown(a) || isUnknown(b)) { - return null - } - return a <= b - } - } - - // Boolean operators - case `and`: - return (data) => { - // 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 - } - case `or`: - return (data) => { - // 3-valued logic for OR: - // - true OR anything = true (short-circuit) - // - null OR anything (except true) = null - // - false OR false = false - let hasUnknown = false - for (const compiledArg of compiledArgs) { - const result = compiledArg(data) - if (result === true) { - return true - } - if (isUnknown(result)) { - hasUnknown = true - } - } - // If we got here, no operand was true - // If any operand was null, return null (UNKNOWN) - if (hasUnknown) { - return null - } - - return false - } - case `not`: { - const arg = compiledArgs[0]! - return (data) => { - // 3-valued logic for NOT: - // - NOT null = null - // - NOT true = false - // - NOT false = true - const result = arg(data) - if (isUnknown(result)) { - return null - } - return !result - } - } - - // Array operators - case `in`: { - const valueEvaluator = compiledArgs[0]! - const arrayEvaluator = compiledArgs[1]! - return (data) => { - const value = valueEvaluator(data) - const array = arrayEvaluator(data) - // In 3-valued logic, if the value is null/undefined, return UNKNOWN - if (isUnknown(value)) { - return null - } - if (!Array.isArray(array)) { - return false - } - return array.includes(value) - } - } - - // String operators - case `like`: { - const valueEvaluator = compiledArgs[0]! - const patternEvaluator = compiledArgs[1]! - return (data) => { - const value = valueEvaluator(data) - const pattern = patternEvaluator(data) - // In 3-valued logic, if value or pattern is null/undefined, return UNKNOWN - if (isUnknown(value) || isUnknown(pattern)) { - return null - } - return evaluateLike(value, pattern, false) - } - } - case `ilike`: { - const valueEvaluator = compiledArgs[0]! - const patternEvaluator = compiledArgs[1]! - return (data) => { - const value = valueEvaluator(data) - const pattern = patternEvaluator(data) - // In 3-valued logic, if value or pattern is null/undefined, return UNKNOWN - if (isUnknown(value) || isUnknown(pattern)) { - return null - } - return evaluateLike(value, pattern, true) - } - } - - // String functions - case `upper`: { - const arg = compiledArgs[0]! - return (data) => { - const value = arg(data) - return typeof value === `string` ? value.toUpperCase() : value - } - } - case `lower`: { - const arg = compiledArgs[0]! - return (data) => { - const value = arg(data) - return typeof value === `string` ? value.toLowerCase() : value - } - } - case `length`: { - const arg = compiledArgs[0]! - return (data) => { - const value = arg(data) - if (typeof value === `string`) { - return value.length - } - if (Array.isArray(value)) { - return value.length - } - return 0 - } - } - case `concat`: - return (data) => { - return compiledArgs - .map((evaluator) => { - const arg = evaluator(data) - try { - return String(arg ?? ``) - } catch { - try { - return JSON.stringify(arg) || `` - } catch { - return `[object]` - } - } - }) - .join(``) - } - case `coalesce`: - return (data) => { - for (const evaluator of compiledArgs) { - const value = evaluator(data) - if (value !== null && value !== undefined) { - return value - } - } - return null - } - - // Math functions - case `add`: { - const argA = compiledArgs[0]! - const argB = compiledArgs[1]! - return (data) => { - const a = argA(data) - const b = argB(data) - return (a ?? 0) + (b ?? 0) - } - } - case `subtract`: { - const argA = compiledArgs[0]! - const argB = compiledArgs[1]! - return (data) => { - const a = argA(data) - const b = argB(data) - return (a ?? 0) - (b ?? 0) - } - } - case `multiply`: { - const argA = compiledArgs[0]! - const argB = compiledArgs[1]! - return (data) => { - const a = argA(data) - const b = argB(data) - return (a ?? 0) * (b ?? 0) - } - } - case `divide`: { - const argA = compiledArgs[0]! - const argB = compiledArgs[1]! - return (data) => { - const a = argA(data) - const b = argB(data) - const divisor = b ?? 0 - return divisor !== 0 ? (a ?? 0) / divisor : null - } - } - - // Null/undefined checking functions - case `isUndefined`: { - const arg = compiledArgs[0]! - return (data) => { - const value = arg(data) - return value === undefined - } - } - case `isNull`: { - const arg = compiledArgs[0]! - return (data) => { - const value = arg(data) - return value === null - } - } - - default: - throw new UnknownFunctionError(func.name) - } -} - -/** - * Evaluates LIKE/ILIKE patterns - */ -function evaluateLike( - value: any, - pattern: any, - caseInsensitive: boolean, -): boolean { - if (typeof value !== `string` || typeof pattern !== `string`) { - return false + // Use the factory embedded in the Func node + if (func.factory) { + return func.factory(compiledArgs, isSingleRow) } - const searchValue = caseInsensitive ? value.toLowerCase() : value - const searchPattern = caseInsensitive ? pattern.toLowerCase() : pattern - - // Convert SQL LIKE pattern to regex - // First escape all regex special chars except % and _ - let regexPattern = searchPattern.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`) - - // Then convert SQL wildcards to regex - regexPattern = regexPattern.replace(/%/g, `.*`) // % matches any sequence - regexPattern = regexPattern.replace(/_/g, `.`) // _ matches any single char - - const regex = new RegExp(`^${regexPattern}$`) - return regex.test(searchValue) + // No factory available - the operator wasn't imported + throw new UnknownFunctionError(func.name) } diff --git a/packages/db/src/query/compiler/expressions.ts b/packages/db/src/query/compiler/expressions.ts index f2856ed7e..37deabcee 100644 --- a/packages/db/src/query/compiler/expressions.ts +++ b/packages/db/src/query/compiler/expressions.ts @@ -48,7 +48,8 @@ export function normalizeExpressionPaths( ) args.push(convertedArg) } - return new Func(whereClause.name, args) + // Preserve the factory from the original Func + return new Func(whereClause.name, args, whereClause.factory) } } diff --git a/packages/db/src/query/compiler/group-by.ts b/packages/db/src/query/compiler/group-by.ts index 84b2a6fb1..f99c934f5 100644 --- a/packages/db/src/query/compiler/group-by.ts +++ b/packages/db/src/query/compiler/group-by.ts @@ -1,10 +1,4 @@ -import { - filter, - groupBy, - groupByOperators, - map, - serializeValue, -} from '@tanstack/db-ivm' +import { filter, groupBy, map, serializeValue } from '@tanstack/db-ivm' import { Func, PropRef, getHavingExpression } from '../ir.js' import { AggregateFunctionNotInSelectError, @@ -22,7 +16,8 @@ import type { } from '../ir.js' import type { NamespacedAndKeyedStream, NamespacedRow } from '../../types.js' -const { sum, count, avg, min, max } = groupByOperators +// Each aggregate's Aggregate node carries its own config. +// No global registry is needed - the config is passed directly to Aggregate. /** * Interface for caching the mapping between GROUP BY expressions and SELECT expressions @@ -349,46 +344,47 @@ function getAggregateFunction(aggExpr: Aggregate) { // Pre-compile the value extractor expression const compiledExpr = compileExpression(aggExpr.args[0]!) - // Create a value extractor function for the expression to aggregate - const valueExtractor = ([, namespacedRow]: [string, NamespacedRow]) => { - const value = compiledExpr(namespacedRow) - // Ensure we return a number for numeric aggregate functions - return typeof value === `number` ? value : value != null ? Number(value) : 0 - } - - // Create a value extractor function for the expression to aggregate - const valueExtractorWithDate = ([, namespacedRow]: [ - string, - NamespacedRow, - ]) => { - const value = compiledExpr(namespacedRow) - return typeof value === `number` || value instanceof Date - ? value - : value != null - ? Number(value) - : 0 + // Use the config embedded in the Aggregate node + const config = aggExpr.config + if (!config) { + throw new UnsupportedAggregateFunctionError(aggExpr.name) } - // Create a raw value extractor function for the expression to aggregate - const rawValueExtractor = ([, namespacedRow]: [string, NamespacedRow]) => { - return compiledExpr(namespacedRow) - } - - // Return the appropriate aggregate function - switch (aggExpr.name.toLowerCase()) { - case `sum`: - return sum(valueExtractor) - case `count`: - return count(rawValueExtractor) - case `avg`: - return avg(valueExtractor) - case `min`: - return min(valueExtractorWithDate) - case `max`: - return max(valueExtractorWithDate) + // Create the appropriate value extractor based on the config + let valueExtractor: (entry: [string, NamespacedRow]) => any + + switch (config.valueTransform) { + case `numeric`: + valueExtractor = ([, namespacedRow]: [string, NamespacedRow]) => { + const value = compiledExpr(namespacedRow) + // Ensure we return a number for numeric aggregate functions + return typeof value === `number` + ? value + : value != null + ? Number(value) + : 0 + } + break + case `numericOrDate`: + valueExtractor = ([, namespacedRow]: [string, NamespacedRow]) => { + const value = compiledExpr(namespacedRow) + return typeof value === `number` || value instanceof Date + ? value + : value != null + ? Number(value) + : 0 + } + break + case `raw`: default: - throw new UnsupportedAggregateFunctionError(aggExpr.name) + valueExtractor = ([, namespacedRow]: [string, NamespacedRow]) => { + return compiledExpr(namespacedRow) + } + break } + + // Return the aggregate function using the embedded factory + return config.factory(valueExtractor) } /** @@ -436,7 +432,8 @@ export function replaceAggregatesByRefs( (arg: BasicExpression | Aggregate) => replaceAggregatesByRefs(arg, selectClause), ) - return new Func(funcExpr.name, transformedArgs) + // Preserve the factory from the original Func + return new Func(funcExpr.name, transformedArgs, funcExpr.factory) } case `ref`: { diff --git a/packages/db/src/query/index.ts b/packages/db/src/query/index.ts index 524b4dcf1..27b5063a7 100644 --- a/packages/db/src/query/index.ts +++ b/packages/db/src/query/index.ts @@ -14,39 +14,66 @@ export { type QueryResult, } from './builder/index.js' -// Expression functions exports +// Expression functions exports - now from operator modules for tree-shaking export { - // Operators + // Comparison operators eq, gt, gte, lt, lte, + // Boolean operators and, or, not, + // Array/pattern operators inArray, like, ilike, + // Null checking isUndefined, isNull, - // Functions + // String functions upper, lower, length, concat, coalesce, + // Math functions add, - // Aggregates + subtract, + multiply, + divide, +} from './builder/operators/index.js' + +// Aggregates - now from aggregate modules for tree-shaking +export { count, avg, sum, min, max, -} from './builder/functions.js' + collect, + minStr, + maxStr, +} from './builder/aggregates/index.js' + +// Types for custom operators and aggregates +// Custom operators: create a Func with your own factory as the 3rd argument +// Custom aggregates: create an Aggregate with your own config as the 3rd argument +export { + Func, + Aggregate, + type EvaluatorFactory, + type CompiledExpression, + type AggregateConfig, + type AggregateFactory, + type ValueExtractor, +} from './ir.js' // Ref proxy utilities export type { Ref } from './builder/types.js' +export { toExpression } from './builder/ref-proxy.js' // Compiler export { compileQuery } from './compiler/index.js' diff --git a/packages/db/src/query/ir.ts b/packages/db/src/query/ir.ts index b1e3d1e07..5ec149a89 100644 --- a/packages/db/src/query/ir.ts +++ b/packages/db/src/query/ir.ts @@ -6,6 +6,37 @@ import type { CompareOptions } from './builder/types' import type { Collection, CollectionImpl } from '../collection/index.js' import type { NamespacedRow } from '../types' +/** + * Type for a compiled expression evaluator + */ +export type CompiledExpression = (data: any) => any + +/** + * Factory function that creates an evaluator from compiled arguments + */ +export type EvaluatorFactory = ( + compiledArgs: Array, + isSingleRow: boolean, +) => CompiledExpression + +/** + * Value extractor for aggregate functions + */ +export type ValueExtractor = (entry: [string, NamespacedRow]) => any + +/** + * Factory function that creates an aggregate from a value extractor + */ +export type AggregateFactory = (valueExtractor: ValueExtractor) => any + +/** + * Configuration for an aggregate function + */ +export interface AggregateConfig { + factory: AggregateFactory + valueTransform: `numeric` | `numericOrDate` | `raw` +} + export interface QueryIR { from: From select?: Select @@ -112,6 +143,7 @@ export class Func extends BaseExpression { constructor( public name: string, // such as eq, gt, lt, upper, lower, etc. public args: Array, + public factory?: EvaluatorFactory, // optional: the evaluator factory for this function ) { super() } @@ -127,6 +159,7 @@ export class Aggregate extends BaseExpression { constructor( public name: string, // such as count, avg, sum, min, max, etc. public args: Array, + public config?: AggregateConfig, // optional: the aggregate configuration ) { super() } diff --git a/packages/db/src/query/optimizer.ts b/packages/db/src/query/optimizer.ts index e1020c284..1b5b7471a 100644 --- a/packages/db/src/query/optimizer.ts +++ b/packages/db/src/query/optimizer.ts @@ -124,13 +124,13 @@ import { deepEquals } from '../utils.js' import { CannotCombineEmptyExpressionListError } from '../errors.js' import { CollectionRef as CollectionRefClass, - Func, PropRef, QueryRef as QueryRefClass, createResidualWhere, getWhereExpression, isResidualWhere, } from './ir.js' +import { and as andBuilder } from './builder/operators/and.js' import type { BasicExpression, From, QueryIR, Select, Where } from './ir.js' /** @@ -1056,6 +1056,6 @@ function combineWithAnd( return expressions[0]! } - // Create an AND function with all expressions as arguments - return new Func(`and`, expressions) + // Use the builder function to create an AND with the proper evaluator factory + return andBuilder(expressions) } diff --git a/packages/db/src/query/predicate-utils.ts b/packages/db/src/query/predicate-utils.ts index 96162e868..06f7ca231 100644 --- a/packages/db/src/query/predicate-utils.ts +++ b/packages/db/src/query/predicate-utils.ts @@ -1,5 +1,7 @@ -import { Func, Value } from './ir.js' -import type { BasicExpression, OrderBy, PropRef } from './ir.js' +import { Value } from './ir.js' +import { or as orBuilder } from './builder/operators/or.js' +import { eq as eqBuilder } from './builder/operators/eq.js' +import type { BasicExpression, Func, OrderBy, PropRef } from './ir.js' import type { LoadSubsetOptions } from '../types.js' /** @@ -52,12 +54,14 @@ function makeDisjunction( if (preds.length === 1) { return preds[0]! } - return new Func(`or`, preds) + // Use the builder function to create an OR with the proper evaluator factory + return orBuilder(preds) } function convertInToOr(inField: InField) { const equalities = inField.values.map( - (value) => new Func(`eq`, [inField.ref, new Value(value)]), + // Use the builder function to create EQ with the proper evaluator factory + (value) => eqBuilder(inField.ref, new Value(value)), ) return makeDisjunction(equalities) } diff --git a/packages/db/tests/collection-change-events.test.ts b/packages/db/tests/collection-change-events.test.ts index be59dde14..9e1c10821 100644 --- a/packages/db/tests/collection-change-events.test.ts +++ b/packages/db/tests/collection-change-events.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createCollection } from '../src/collection/index.js' import { currentStateAsChanges } from '../src/collection/change-events.js' -import { Func, PropRef, Value } from '../src/query/ir.js' +import { PropRef, Value } from '../src/query/ir.js' import { DEFAULT_COMPARE_OPTIONS } from '../src/utils.js' +import { eq, gt } from '../src/query/builder/operators/index.js' interface TestUser { id: string @@ -90,7 +91,7 @@ describe(`currentStateAsChanges`, () => { ) const result = currentStateAsChanges(collection, { - where: new Func(`eq`, [new PropRef([`status`]), new Value(`active`)]), + where: eq(new PropRef([`status`]), new Value(`active`)), }) expect(result).toHaveLength(3) @@ -107,7 +108,7 @@ describe(`currentStateAsChanges`, () => { ) const result = currentStateAsChanges(collection, { - where: new Func(`gt`, [new PropRef([`age`]), new Value(25)]), + where: gt(new PropRef([`age`]), new Value(25)), }) expect(result).toHaveLength(3) @@ -225,7 +226,7 @@ describe(`currentStateAsChanges`, () => { ) const result = currentStateAsChanges(collection, { - where: new Func(`eq`, [new PropRef([`status`]), new Value(`active`)]), + where: eq(new PropRef([`status`]), new Value(`active`)), orderBy: [ { expression: new PropRef([`score`]), @@ -248,7 +249,7 @@ describe(`currentStateAsChanges`, () => { ) const result = currentStateAsChanges(collection, { - where: new Func(`gt`, [new PropRef([`age`]), new Value(25)]), + where: gt(new PropRef([`age`]), new Value(25)), orderBy: [ { expression: new PropRef([`age`]), @@ -271,7 +272,7 @@ describe(`currentStateAsChanges`, () => { ) const result = currentStateAsChanges(collection, { - where: new Func(`eq`, [new PropRef([`status`]), new Value(`active`)]), + where: eq(new PropRef([`status`]), new Value(`active`)), orderBy: [ { expression: new PropRef([`score`]), @@ -314,10 +315,7 @@ describe(`currentStateAsChanges`, () => { expect(() => { currentStateAsChanges(collection, { - where: new Func(`eq`, [ - new PropRef([`status`]), - new Value(`active`), - ]), + where: eq(new PropRef([`status`]), new Value(`active`)), limit: 3, }) }).toThrow(`limit cannot be used without orderBy`) diff --git a/packages/db/tests/query/compiler/basic.test.ts b/packages/db/tests/query/compiler/basic.test.ts index 66b7a1514..eb2d3017a 100644 --- a/packages/db/tests/query/compiler/basic.test.ts +++ b/packages/db/tests/query/compiler/basic.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from 'vitest' import { D2, MultiSet, output } from '@tanstack/db-ivm' import { compileQuery } from '../../../src/query/compiler/index.js' -import { CollectionRef, Func, PropRef, Value } from '../../../src/query/ir.js' +import { CollectionRef, PropRef, Value } from '../../../src/query/ir.js' +import { and, eq, gt } from '../../../src/query/builder/operators/index.js' import type { QueryIR } from '../../../src/query/ir.js' import type { CollectionImpl } from '../../../src/collection/index.js' @@ -172,7 +173,7 @@ describe(`Query2 Compiler`, () => { name: new PropRef([`users`, `name`]), age: new PropRef([`users`, `age`]), }, - where: [new Func(`gt`, [new PropRef([`users`, `age`]), new Value(20)])], + where: [gt(new PropRef([`users`, `age`]), new Value(20))], } const graph = new D2() @@ -234,10 +235,10 @@ describe(`Query2 Compiler`, () => { name: new PropRef([`users`, `name`]), }, where: [ - new Func(`and`, [ - new Func(`gt`, [new PropRef([`users`, `age`]), new Value(20)]), - new Func(`eq`, [new PropRef([`users`, `active`]), new Value(true)]), - ]), + and( + gt(new PropRef([`users`, `age`]), new Value(20)), + eq(new PropRef([`users`, `active`]), new Value(true)), + ), ], } diff --git a/packages/db/tests/query/compiler/custom-aggregates.test.ts b/packages/db/tests/query/compiler/custom-aggregates.test.ts new file mode 100644 index 000000000..d81308b29 --- /dev/null +++ b/packages/db/tests/query/compiler/custom-aggregates.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from 'vitest' +import { createCollection } from '../../../src/collection/index.js' +import { + Aggregate, + avg, + count, + createLiveQueryCollection, + sum, +} from '../../../src/query/index.js' +import { toExpression } from '../../../src/query/builder/ref-proxy.js' +import { mockSyncCollectionOptions } from '../../utils.js' +import type { + AggregateConfig, + ValueExtractor, +} from '../../../src/query/index.js' + +interface TestItem { + id: number + category: string + price: number + quantity: number +} + +const sampleItems: Array = [ + { id: 1, category: `A`, price: 10, quantity: 2 }, + { id: 2, category: `A`, price: 20, quantity: 3 }, + { id: 3, category: `B`, price: 15, quantity: 1 }, + { id: 4, category: `B`, price: 25, quantity: 4 }, +] + +function createTestCollection() { + return createCollection( + mockSyncCollectionOptions({ + id: `test-custom-aggregates`, + getKey: (item) => item.id, + initialData: sampleItems, + }), + ) +} + +// Custom aggregate configs (following the same pattern as built-in aggregates) +const productConfig: AggregateConfig = { + factory: (valueExtractor: ValueExtractor) => ({ + preMap: valueExtractor, + reduce: (values: Array<[number, number]>) => { + let result = 1 + for (const [value, multiplicity] of values) { + // For positive multiplicity, multiply the value that many times + // For negative multiplicity, divide (inverse operation for IVM) + if (multiplicity > 0) { + for (let i = 0; i < multiplicity; i++) { + result *= value + } + } else if (multiplicity < 0) { + for (let i = 0; i < -multiplicity; i++) { + result /= value + } + } + } + return result + }, + }), + valueTransform: `numeric`, +} + +const varianceConfig: AggregateConfig = { + factory: (valueExtractor: ValueExtractor) => ({ + preMap: (data: any) => { + const value = valueExtractor(data) + return { sum: value, sumSq: value * value, n: 1 } + }, + reduce: ( + values: Array<[{ sum: number; sumSq: number; n: number }, number]>, + ) => { + let totalSum = 0 + let totalSumSq = 0 + let totalN = 0 + for (const [{ sum: s, sumSq, n }, multiplicity] of values) { + totalSum += s * multiplicity + totalSumSq += sumSq * multiplicity + totalN += n * multiplicity + } + return { sum: totalSum, sumSq: totalSumSq, n: totalN } + }, + postMap: (acc: { sum: number; sumSq: number; n: number }) => { + if (acc.n === 0) return 0 + const mean = acc.sum / acc.n + return acc.sumSq / acc.n - mean * mean + }, + }), + valueTransform: `raw`, // We handle the transformation in preMap +} + +// Custom aggregate builder functions (pass config as 3rd argument to Aggregate) +function product(arg: T): Aggregate { + return new Aggregate(`product`, [toExpression(arg)], productConfig) +} + +function variance(arg: T): Aggregate { + return new Aggregate(`variance`, [toExpression(arg)], varianceConfig) +} + +describe(`Custom Aggregates`, () => { + describe(`custom aggregate builder functions`, () => { + it(`creates an Aggregate IR node for product with embedded config`, () => { + const agg = product(10) + expect(agg.type).toBe(`agg`) + expect(agg.name).toBe(`product`) + expect(agg.args).toHaveLength(1) + expect(agg.config).toBe(productConfig) + }) + + it(`creates an Aggregate IR node for variance with embedded config`, () => { + const agg = variance(10) + expect(agg.type).toBe(`agg`) + expect(agg.name).toBe(`variance`) + expect(agg.args).toHaveLength(1) + expect(agg.config).toBe(varianceConfig) + }) + }) + + describe(`custom aggregates in queries`, () => { + it(`product aggregate multiplies values in a group`, () => { + const collection = createTestCollection() + + const result = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ items: collection }) + .groupBy(({ items }) => items.category) + .select(({ items }) => ({ + category: items.category, + priceProduct: product(items.price), + })), + }) + + expect(result.size).toBe(2) + + const categoryA = result.get(`A`) + const categoryB = result.get(`B`) + + expect(categoryA?.priceProduct).toBe(200) // 10 * 20 + expect(categoryB?.priceProduct).toBe(375) // 15 * 25 + }) + + it(`variance aggregate calculates population variance`, () => { + const collection = createTestCollection() + + const result = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ items: collection }) + .groupBy(({ items }) => items.category) + .select(({ items }) => ({ + category: items.category, + priceVariance: variance(items.price), + })), + }) + + expect(result.size).toBe(2) + + // Category A: prices 10, 20 -> mean = 15, variance = ((10-15)² + (20-15)²) / 2 = 25 + const categoryA = result.get(`A`) + expect(categoryA?.priceVariance).toBe(25) + + // Category B: prices 15, 25 -> mean = 20, variance = ((15-20)² + (25-20)²) / 2 = 25 + const categoryB = result.get(`B`) + expect(categoryB?.priceVariance).toBe(25) + }) + + it(`custom aggregates work alongside built-in aggregates`, () => { + const collection = createTestCollection() + + const result = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ items: collection }) + .groupBy(({ items }) => items.category) + .select(({ items }) => ({ + category: items.category, + totalPrice: sum(items.price), + avgPrice: avg(items.price), + itemCount: count(items.id), + priceProduct: product(items.price), + })), + }) + + expect(result.size).toBe(2) + + const categoryA = result.get(`A`) + expect(categoryA?.totalPrice).toBe(30) // 10 + 20 + expect(categoryA?.avgPrice).toBe(15) // (10 + 20) / 2 + expect(categoryA?.itemCount).toBe(2) + expect(categoryA?.priceProduct).toBe(200) // 10 * 20 + }) + + it(`custom aggregates work with single-group aggregation (empty GROUP BY)`, () => { + const collection = createTestCollection() + + const result = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ items: collection }) + .groupBy(() => ({})) + .select(({ items }) => ({ + totalProduct: product(items.price), + })), + }) + + expect(result.size).toBe(1) + // 10 * 20 * 15 * 25 = 75000 + expect(result.toArray[0]?.totalProduct).toBe(75000) + }) + }) +}) diff --git a/packages/db/tests/query/compiler/custom-operators.test.ts b/packages/db/tests/query/compiler/custom-operators.test.ts new file mode 100644 index 000000000..80f0ffbe4 --- /dev/null +++ b/packages/db/tests/query/compiler/custom-operators.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest' +import { compileExpression } from '../../../src/query/compiler/evaluators.js' +import { Func, PropRef, Value } from '../../../src/query/ir.js' +import { toExpression } from '../../../src/query/builder/ref-proxy.js' +import { and } from '../../../src/query/builder/operators/index.js' +import type { + BasicExpression, + CompiledExpression, + EvaluatorFactory, +} from '../../../src/query/ir.js' + +describe(`custom operators`, () => { + // Define factory for the "between" operator + const betweenFactory: EvaluatorFactory = ( + compiledArgs: Array, + _isSingleRow: boolean, + ): CompiledExpression => { + const valueEval = compiledArgs[0]! + const minEval = compiledArgs[1]! + const maxEval = compiledArgs[2]! + + return (data: any) => { + const value = valueEval(data) + const min = minEval(data) + const max = maxEval(data) + + if (value === null || value === undefined) { + return null // 3-valued logic + } + + return value >= min && value <= max + } + } + + // Builder function for "between" operator + function between(value: any, min: any, max: any): BasicExpression { + return new Func( + `between`, + [toExpression(value), toExpression(min), toExpression(max)], + betweenFactory, + ) + } + + describe(`custom operator pattern`, () => { + it(`allows creating a custom "between" operator`, () => { + // Test the custom operator + const func = between(new Value(5), new Value(1), new Value(10)) + const compiled = compileExpression(func) + + expect(compiled({})).toBe(true) + }) + + it(`custom "between" operator returns false when out of range`, () => { + const func = between(new Value(15), new Value(1), new Value(10)) + const compiled = compileExpression(func) + + expect(compiled({})).toBe(false) + }) + + it(`custom "between" operator handles null with 3-valued logic`, () => { + const func = between(new Value(null), new Value(1), new Value(10)) + const compiled = compileExpression(func) + + expect(compiled({})).toBe(null) + }) + + it(`allows creating a custom "startsWith" operator`, () => { + const startsWithFactory: EvaluatorFactory = ( + compiledArgs: Array, + _isSingleRow: boolean, + ): CompiledExpression => { + const strEval = compiledArgs[0]! + const prefixEval = compiledArgs[1]! + + return (data: any) => { + const str = strEval(data) + const prefix = prefixEval(data) + + if (str === null || str === undefined) { + return null + } + if (typeof str !== `string` || typeof prefix !== `string`) { + return false + } + + return str.startsWith(prefix) + } + } + + function startsWith(str: any, prefix: any): BasicExpression { + return new Func( + `startsWith`, + [toExpression(str), toExpression(prefix)], + startsWithFactory, + ) + } + + const func = startsWith(new Value(`hello world`), new Value(`hello`)) + const compiled = compileExpression(func) + + expect(compiled({})).toBe(true) + }) + + it(`custom operator works with property references`, () => { + // Define a custom "isEmpty" operator + const isEmptyFactory: EvaluatorFactory = ( + compiledArgs: Array, + _isSingleRow: boolean, + ): CompiledExpression => { + const valueEval = compiledArgs[0]! + + return (data: any) => { + const value = valueEval(data) + + if (value === null || value === undefined) { + return true + } + if (typeof value === `string`) { + return value.length === 0 + } + if (Array.isArray(value)) { + return value.length === 0 + } + + return false + } + } + + function isEmpty(value: any): BasicExpression { + return new Func(`isEmpty`, [toExpression(value)], isEmptyFactory) + } + + // Test with a property reference + const func = isEmpty(new PropRef([`users`, `name`])) + const compiled = compileExpression(func) + + expect(compiled({ users: { name: `` } })).toBe(true) + expect(compiled({ users: { name: `John` } })).toBe(false) + expect(compiled({ users: { name: null } })).toBe(true) + }) + + it(`allows creating a custom "modulo" operator`, () => { + const moduloFactory: EvaluatorFactory = ( + compiledArgs: Array, + _isSingleRow: boolean, + ): CompiledExpression => { + const leftEval = compiledArgs[0]! + const rightEval = compiledArgs[1]! + + return (data: any) => { + const left = leftEval(data) + const right = rightEval(data) + + if (left === null || left === undefined) { + return null + } + if (right === 0) { + return null // Division by zero + } + + return left % right + } + } + + function modulo(left: any, right: any): BasicExpression { + return new Func( + `modulo`, + [toExpression(left), toExpression(right)], + moduloFactory, + ) + } + + const func = modulo(new Value(10), new Value(3)) + const compiled = compileExpression(func) + + expect(compiled({})).toBe(1) + }) + + it(`custom operator can be used in nested expressions`, () => { + // Use the "between" operator with an "and" operator + const func = and( + between(new Value(5), new Value(1), new Value(10)), + between(new Value(15), new Value(10), new Value(20)), + ) + const compiled = compileExpression(func) + + expect(compiled({})).toBe(true) + }) + + it(`custom operator with extended behavior`, () => { + // Define a custom version of "length" that also handles objects + const customLengthFactory: EvaluatorFactory = ( + compiledArgs: Array, + _isSingleRow: boolean, + ): CompiledExpression => { + const valueEval = compiledArgs[0]! + + return (data: any) => { + const value = valueEval(data) + + if (typeof value === `string`) { + return value.length + } + if (Array.isArray(value)) { + return value.length + } + if (value && typeof value === `object`) { + return Object.keys(value).length + } + + return 0 + } + } + + function customLength(value: any): BasicExpression { + return new Func( + `customLength`, + [toExpression(value)], + customLengthFactory, + ) + } + + const func = customLength(new Value({ a: 1, b: 2, c: 3 })) + const compiled = compileExpression(func) + + expect(compiled({})).toBe(3) + }) + }) + + describe(`builder function pattern`, () => { + it(`demonstrates the full pattern for custom operators`, () => { + // This demonstrates the full pattern users would use + + // 1. The builder function was already defined above (between) + // It includes both the factory and the builder function + + // 2. Use it like any other operator + const expr = between(new PropRef([`users`, `age`]), 18, 65) + + // 3. Compile and execute + const compiled = compileExpression(expr) + + expect(compiled({ users: { age: 30 } })).toBe(true) + expect(compiled({ users: { age: 10 } })).toBe(false) + expect(compiled({ users: { age: 70 } })).toBe(false) + }) + }) +}) diff --git a/packages/db/tests/query/compiler/evaluators.test.ts b/packages/db/tests/query/compiler/evaluators.test.ts index 68e08d7ea..7803abe4e 100644 --- a/packages/db/tests/query/compiler/evaluators.test.ts +++ b/packages/db/tests/query/compiler/evaluators.test.ts @@ -1,6 +1,28 @@ import { describe, expect, it } from 'vitest' import { compileExpression } from '../../../src/query/compiler/evaluators.js' import { Func, PropRef, Value } from '../../../src/query/ir.js' +import { + add, + and, + coalesce, + concat, + divide, + eq, + gt, + gte, + ilike, + inArray, + length, + like, + lower, + lt, + lte, + multiply, + not, + or, + subtract, + upper, +} from '../../../src/query/builder/operators/index.js' import type { NamespacedRow } from '../../../src/types.js' describe(`evaluators`, () => { @@ -81,42 +103,42 @@ describe(`evaluators`, () => { describe(`string functions`, () => { it(`handles upper with non-string value`, () => { - const func = new Func(`upper`, [new Value(42)]) + const func = upper(new Value(42)) const compiled = compileExpression(func) expect(compiled({})).toBe(42) }) it(`handles lower with non-string value`, () => { - const func = new Func(`lower`, [new Value(true)]) + const func = lower(new Value(true)) const compiled = compileExpression(func) expect(compiled({})).toBe(true) }) it(`handles length with non-string, non-array value`, () => { - const func = new Func(`length`, [new Value(42)]) + const func = length(new Value(42)) const compiled = compileExpression(func) expect(compiled({})).toBe(0) }) it(`handles length with array`, () => { - const func = new Func(`length`, [new Value([1, 2, 3])]) + const func = length(new Value([1, 2, 3])) const compiled = compileExpression(func) expect(compiled({})).toBe(3) }) it(`handles concat with various types`, () => { - const func = new Func(`concat`, [ + const func = concat( new Value(`Hello`), new Value(null), new Value(undefined), new Value(42), new Value({ a: 1 }), new Value([1, 2, 3]), - ]) + ) const compiled = compileExpression(func) const result = compiled({}) @@ -128,7 +150,7 @@ describe(`evaluators`, () => { const circular: any = {} circular.self = circular - const func = new Func(`concat`, [new Value(circular)]) + const func = concat(new Value(circular)) const compiled = compileExpression(func) // Should not throw and should return some fallback string @@ -137,22 +159,22 @@ describe(`evaluators`, () => { }) it(`handles coalesce with all null/undefined values`, () => { - const func = new Func(`coalesce`, [ + const func = coalesce( new Value(null), new Value(undefined), new Value(null), - ]) + ) const compiled = compileExpression(func) expect(compiled({})).toBeNull() }) it(`handles coalesce with first non-null value`, () => { - const func = new Func(`coalesce`, [ + const func = coalesce( new Value(null), new Value(`first`), new Value(`second`), - ]) + ) const compiled = compileExpression(func) expect(compiled({})).toBe(`first`) @@ -161,24 +183,21 @@ describe(`evaluators`, () => { describe(`array functions`, () => { it(`handles in with non-array value`, () => { - const func = new Func(`in`, [new Value(1), new Value(`not an array`)]) + const func = inArray(new Value(1), new Value(`not an array`)) const compiled = compileExpression(func) expect(compiled({})).toBe(false) }) it(`handles in with array`, () => { - const func = new Func(`in`, [ - new Value(2), - new Value([1, 2, 3, null]), - ]) + const func = inArray(new Value(2), new Value([1, 2, 3, null])) const compiled = compileExpression(func) expect(compiled({})).toBe(true) }) it(`handles in with null value (3-valued logic)`, () => { - const func = new Func(`in`, [new Value(null), new Value([1, 2, 3])]) + const func = inArray(new Value(null), new Value([1, 2, 3])) const compiled = compileExpression(func) // In 3-valued logic, null in array returns UNKNOWN (null) @@ -186,10 +205,7 @@ describe(`evaluators`, () => { }) it(`handles in with undefined value (3-valued logic)`, () => { - const func = new Func(`in`, [ - new Value(undefined), - new Value([1, 2, 3]), - ]) + const func = inArray(new Value(undefined), new Value([1, 2, 3])) const compiled = compileExpression(func) // In 3-valued logic, undefined in array returns UNKNOWN (null) @@ -199,35 +215,35 @@ describe(`evaluators`, () => { describe(`math functions`, () => { it(`handles add with null values (should default to 0)`, () => { - const func = new Func(`add`, [new Value(null), new Value(undefined)]) + const func = add(new Value(null), new Value(undefined)) const compiled = compileExpression(func) expect(compiled({})).toBe(0) }) it(`handles subtract with null values`, () => { - const func = new Func(`subtract`, [new Value(null), new Value(5)]) + const func = subtract(new Value(null), new Value(5)) const compiled = compileExpression(func) expect(compiled({})).toBe(-5) }) it(`handles multiply with null values`, () => { - const func = new Func(`multiply`, [new Value(null), new Value(5)]) + const func = multiply(new Value(null), new Value(5)) const compiled = compileExpression(func) expect(compiled({})).toBe(0) }) it(`handles divide with zero divisor`, () => { - const func = new Func(`divide`, [new Value(10), new Value(0)]) + const func = divide(new Value(10), new Value(0)) const compiled = compileExpression(func) expect(compiled({})).toBeNull() }) it(`handles divide with null values`, () => { - const func = new Func(`divide`, [new Value(null), new Value(null)]) + const func = divide(new Value(null), new Value(null)) const compiled = compileExpression(func) expect(compiled({})).toBeNull() @@ -236,51 +252,42 @@ describe(`evaluators`, () => { describe(`like/ilike functions`, () => { it(`handles like with non-string value`, () => { - const func = new Func(`like`, [new Value(42), new Value(`%2%`)]) + const func = like(new Value(42), new Value(`%2%`)) const compiled = compileExpression(func) expect(compiled({})).toBe(false) }) it(`handles like with non-string pattern`, () => { - const func = new Func(`like`, [new Value(`hello`), new Value(42)]) + const func = like(new Value(`hello`), new Value(42)) const compiled = compileExpression(func) expect(compiled({})).toBe(false) }) it(`handles like with wildcard patterns`, () => { - const func = new Func(`like`, [ - new Value(`hello world`), - new Value(`hello%`), - ]) + const func = like(new Value(`hello world`), new Value(`hello%`)) const compiled = compileExpression(func) expect(compiled({})).toBe(true) }) it(`handles like with single character wildcard`, () => { - const func = new Func(`like`, [ - new Value(`hello`), - new Value(`hell_`), - ]) + const func = like(new Value(`hello`), new Value(`hell_`)) const compiled = compileExpression(func) expect(compiled({})).toBe(true) }) it(`handles like with regex special characters`, () => { - const func = new Func(`like`, [ - new Value(`test.string`), - new Value(`test.string`), - ]) + const func = like(new Value(`test.string`), new Value(`test.string`)) const compiled = compileExpression(func) expect(compiled({})).toBe(true) }) it(`handles like with null value (3-valued logic)`, () => { - const func = new Func(`like`, [new Value(null), new Value(`hello%`)]) + const func = like(new Value(null), new Value(`hello%`)) const compiled = compileExpression(func) // In 3-valued logic, like with null value returns UNKNOWN (null) @@ -288,10 +295,7 @@ describe(`evaluators`, () => { }) it(`handles like with undefined value (3-valued logic)`, () => { - const func = new Func(`like`, [ - new Value(undefined), - new Value(`hello%`), - ]) + const func = like(new Value(undefined), new Value(`hello%`)) const compiled = compileExpression(func) // In 3-valued logic, like with undefined value returns UNKNOWN (null) @@ -299,7 +303,7 @@ describe(`evaluators`, () => { }) it(`handles like with null pattern (3-valued logic)`, () => { - const func = new Func(`like`, [new Value(`hello`), new Value(null)]) + const func = like(new Value(`hello`), new Value(null)) const compiled = compileExpression(func) // In 3-valued logic, like with null pattern returns UNKNOWN (null) @@ -307,10 +311,7 @@ describe(`evaluators`, () => { }) it(`handles like with undefined pattern (3-valued logic)`, () => { - const func = new Func(`like`, [ - new Value(`hello`), - new Value(undefined), - ]) + const func = like(new Value(`hello`), new Value(undefined)) const compiled = compileExpression(func) // In 3-valued logic, like with undefined pattern returns UNKNOWN (null) @@ -318,27 +319,21 @@ describe(`evaluators`, () => { }) it(`handles ilike (case insensitive)`, () => { - const func = new Func(`ilike`, [ - new Value(`HELLO`), - new Value(`hello`), - ]) + const func = ilike(new Value(`HELLO`), new Value(`hello`)) const compiled = compileExpression(func) expect(compiled({})).toBe(true) }) it(`handles ilike with patterns`, () => { - const func = new Func(`ilike`, [ - new Value(`HELLO WORLD`), - new Value(`hello%`), - ]) + const func = ilike(new Value(`HELLO WORLD`), new Value(`hello%`)) const compiled = compileExpression(func) expect(compiled({})).toBe(true) }) it(`handles ilike with null value (3-valued logic)`, () => { - const func = new Func(`ilike`, [new Value(null), new Value(`hello%`)]) + const func = ilike(new Value(null), new Value(`hello%`)) const compiled = compileExpression(func) // In 3-valued logic, ilike with null value returns UNKNOWN (null) @@ -346,10 +341,7 @@ describe(`evaluators`, () => { }) it(`handles ilike with undefined value (3-valued logic)`, () => { - const func = new Func(`ilike`, [ - new Value(undefined), - new Value(`hello%`), - ]) + const func = ilike(new Value(undefined), new Value(`hello%`)) const compiled = compileExpression(func) // In 3-valued logic, ilike with undefined value returns UNKNOWN (null) @@ -357,7 +349,7 @@ describe(`evaluators`, () => { }) it(`handles ilike with null pattern (3-valued logic)`, () => { - const func = new Func(`ilike`, [new Value(`hello`), new Value(null)]) + const func = ilike(new Value(`hello`), new Value(null)) const compiled = compileExpression(func) // In 3-valued logic, ilike with null pattern returns UNKNOWN (null) @@ -365,10 +357,7 @@ describe(`evaluators`, () => { }) it(`handles ilike with undefined pattern (3-valued logic)`, () => { - const func = new Func(`ilike`, [ - new Value(`hello`), - new Value(undefined), - ]) + const func = ilike(new Value(`hello`), new Value(undefined)) const compiled = compileExpression(func) // In 3-valued logic, ilike with undefined pattern returns UNKNOWN (null) @@ -379,7 +368,7 @@ describe(`evaluators`, () => { describe(`comparison operators`, () => { describe(`eq (equality)`, () => { it(`handles eq with null and null (3-valued logic)`, () => { - const func = new Func(`eq`, [new Value(null), new Value(null)]) + const func = eq(new Value(null), new Value(null)) const compiled = compileExpression(func) // In 3-valued logic, null = null returns UNKNOWN (null) @@ -387,7 +376,7 @@ describe(`evaluators`, () => { }) it(`handles eq with null and value (3-valued logic)`, () => { - const func = new Func(`eq`, [new Value(null), new Value(5)]) + const func = eq(new Value(null), new Value(5)) const compiled = compileExpression(func) // In 3-valued logic, null = value returns UNKNOWN (null) @@ -395,7 +384,7 @@ describe(`evaluators`, () => { }) it(`handles eq with value and null (3-valued logic)`, () => { - const func = new Func(`eq`, [new Value(5), new Value(null)]) + const func = eq(new Value(5), new Value(null)) const compiled = compileExpression(func) // In 3-valued logic, value = null returns UNKNOWN (null) @@ -403,7 +392,7 @@ describe(`evaluators`, () => { }) it(`handles eq with undefined and value (3-valued logic)`, () => { - const func = new Func(`eq`, [new Value(undefined), new Value(5)]) + const func = eq(new Value(undefined), new Value(5)) const compiled = compileExpression(func) // In 3-valued logic, undefined = value returns UNKNOWN (null) @@ -411,14 +400,14 @@ describe(`evaluators`, () => { }) it(`handles eq with matching values`, () => { - const func = new Func(`eq`, [new Value(5), new Value(5)]) + const func = eq(new Value(5), new Value(5)) const compiled = compileExpression(func) expect(compiled({})).toBe(true) }) it(`handles eq with non-matching values`, () => { - const func = new Func(`eq`, [new Value(5), new Value(10)]) + const func = eq(new Value(5), new Value(10)) const compiled = compileExpression(func) expect(compiled({})).toBe(false) @@ -427,7 +416,7 @@ describe(`evaluators`, () => { it(`handles eq with matching Uint8Arrays (content equality)`, () => { const array1 = new Uint8Array([1, 2, 3, 4, 5]) const array2 = new Uint8Array([1, 2, 3, 4, 5]) - const func = new Func(`eq`, [new Value(array1), new Value(array2)]) + const func = eq(new Value(array1), new Value(array2)) const compiled = compileExpression(func) // Should return true because content is the same @@ -437,7 +426,7 @@ describe(`evaluators`, () => { it(`handles eq with non-matching Uint8Arrays (different content)`, () => { const array1 = new Uint8Array([1, 2, 3, 4, 5]) const array2 = new Uint8Array([1, 2, 3, 4, 6]) - const func = new Func(`eq`, [new Value(array1), new Value(array2)]) + const func = eq(new Value(array1), new Value(array2)) const compiled = compileExpression(func) // Should return false because content is different @@ -447,7 +436,7 @@ describe(`evaluators`, () => { it(`handles eq with Uint8Arrays of different lengths`, () => { const array1 = new Uint8Array([1, 2, 3, 4]) const array2 = new Uint8Array([1, 2, 3, 4, 5]) - const func = new Func(`eq`, [new Value(array1), new Value(array2)]) + const func = eq(new Value(array1), new Value(array2)) const compiled = compileExpression(func) // Should return false because lengths are different @@ -456,7 +445,7 @@ describe(`evaluators`, () => { it(`handles eq with same Uint8Array reference`, () => { const array = new Uint8Array([1, 2, 3, 4, 5]) - const func = new Func(`eq`, [new Value(array), new Value(array)]) + const func = eq(new Value(array), new Value(array)) const compiled = compileExpression(func) // Should return true (fast path for reference equality) @@ -466,7 +455,8 @@ describe(`evaluators`, () => { it(`handles eq with Uint8Array and non-Uint8Array`, () => { const array = new Uint8Array([1, 2, 3]) const value = [1, 2, 3] - const func = new Func(`eq`, [new Value(array), new Value(value)]) + // Cast to any to test runtime behavior with mismatched types + const func = eq(new Value(array) as any, new Value(value) as any) const compiled = compileExpression(func) // Should return false because types are different @@ -484,7 +474,7 @@ describe(`evaluators`, () => { ulid2[i] = i } - const func = new Func(`eq`, [new Value(ulid1), new Value(ulid2)]) + const func = eq(new Value(ulid1), new Value(ulid2)) const compiled = compileExpression(func) // Should return true because content is identical @@ -495,10 +485,7 @@ describe(`evaluators`, () => { if (typeof Buffer !== `undefined`) { const buffer1 = Buffer.from([1, 2, 3, 4, 5]) const buffer2 = Buffer.from([1, 2, 3, 4, 5]) - const func = new Func(`eq`, [ - new Value(buffer1), - new Value(buffer2), - ]) + const func = eq(new Value(buffer1), new Value(buffer2)) const compiled = compileExpression(func) // Should return true because content is the same @@ -517,11 +504,10 @@ describe(`evaluators`, () => { // But they have the same time value expect(date1.getTime()).toBe(date2.getTime()) - const func = new Func(`eq`, [new Value(date1), new Value(date2)]) + const func = eq(new Value(date1), new Value(date2)) const compiled = compileExpression(func) // Should return true because they represent the same time - // Currently this fails because eq() does referential comparison expect(compiled({})).toBe(true) }) @@ -529,7 +515,7 @@ describe(`evaluators`, () => { const date1 = new Date(`2024-01-15T10:30:45.123Z`) const date2 = new Date(`2024-01-15T10:30:45.124Z`) // 1ms later - const func = new Func(`eq`, [new Value(date1), new Value(date2)]) + const func = eq(new Value(date1), new Value(date2)) const compiled = compileExpression(func) // Should return false because they represent different times @@ -540,7 +526,7 @@ describe(`evaluators`, () => { // Reproduction of user's issue: new Uint8Array(5) creates [0,0,0,0,0] const array1 = new Uint8Array(5) // Creates array of length 5, all zeros const array2 = new Uint8Array(5) // Creates another array of length 5, all zeros - const func = new Func(`eq`, [new Value(array1), new Value(array2)]) + const func = eq(new Value(array1), new Value(array2)) const compiled = compileExpression(func) // Should return true because both have same content (all zeros) @@ -550,7 +536,7 @@ describe(`evaluators`, () => { it(`handles eq with empty Uint8Arrays`, () => { const array1 = new Uint8Array(0) const array2 = new Uint8Array(0) - const func = new Func(`eq`, [new Value(array1), new Value(array2)]) + const func = eq(new Value(array1), new Value(array2)) const compiled = compileExpression(func) // Empty arrays should be equal @@ -558,27 +544,21 @@ describe(`evaluators`, () => { }) it(`still handles eq with strings correctly`, () => { - const func1 = new Func(`eq`, [ - new Value(`hello`), - new Value(`hello`), - ]) + const func1 = eq(new Value(`hello`), new Value(`hello`)) const compiled1 = compileExpression(func1) expect(compiled1({})).toBe(true) - const func2 = new Func(`eq`, [ - new Value(`hello`), - new Value(`world`), - ]) + const func2 = eq(new Value(`hello`), new Value(`world`)) const compiled2 = compileExpression(func2) expect(compiled2({})).toBe(false) }) it(`still handles eq with numbers correctly`, () => { - const func1 = new Func(`eq`, [new Value(42), new Value(42)]) + const func1 = eq(new Value(42), new Value(42)) const compiled1 = compileExpression(func1) expect(compiled1({})).toBe(true) - const func2 = new Func(`eq`, [new Value(42), new Value(43)]) + const func2 = eq(new Value(42), new Value(43)) const compiled2 = compileExpression(func2) expect(compiled2({})).toBe(false) }) @@ -586,7 +566,7 @@ describe(`evaluators`, () => { describe(`gt (greater than)`, () => { it(`handles gt with null and value (3-valued logic)`, () => { - const func = new Func(`gt`, [new Value(null), new Value(5)]) + const func = gt(new Value(null), new Value(5)) const compiled = compileExpression(func) // In 3-valued logic, null > value returns UNKNOWN (null) @@ -594,7 +574,7 @@ describe(`evaluators`, () => { }) it(`handles gt with value and null (3-valued logic)`, () => { - const func = new Func(`gt`, [new Value(5), new Value(null)]) + const func = gt(new Value(5), new Value(null)) const compiled = compileExpression(func) // In 3-valued logic, value > null returns UNKNOWN (null) @@ -602,7 +582,7 @@ describe(`evaluators`, () => { }) it(`handles gt with undefined (3-valued logic)`, () => { - const func = new Func(`gt`, [new Value(undefined), new Value(5)]) + const func = gt(new Value(undefined), new Value(5)) const compiled = compileExpression(func) // In 3-valued logic, undefined > value returns UNKNOWN (null) @@ -610,7 +590,7 @@ describe(`evaluators`, () => { }) it(`handles gt with valid values`, () => { - const func = new Func(`gt`, [new Value(10), new Value(5)]) + const func = gt(new Value(10), new Value(5)) const compiled = compileExpression(func) expect(compiled({})).toBe(true) @@ -619,7 +599,7 @@ describe(`evaluators`, () => { describe(`gte (greater than or equal)`, () => { it(`handles gte with null (3-valued logic)`, () => { - const func = new Func(`gte`, [new Value(null), new Value(5)]) + const func = gte(new Value(null), new Value(5)) const compiled = compileExpression(func) // In 3-valued logic, null >= value returns UNKNOWN (null) @@ -627,7 +607,7 @@ describe(`evaluators`, () => { }) it(`handles gte with undefined (3-valued logic)`, () => { - const func = new Func(`gte`, [new Value(undefined), new Value(5)]) + const func = gte(new Value(undefined), new Value(5)) const compiled = compileExpression(func) // In 3-valued logic, undefined >= value returns UNKNOWN (null) @@ -637,7 +617,7 @@ describe(`evaluators`, () => { describe(`lt (less than)`, () => { it(`handles lt with null (3-valued logic)`, () => { - const func = new Func(`lt`, [new Value(null), new Value(5)]) + const func = lt(new Value(null), new Value(5)) const compiled = compileExpression(func) // In 3-valued logic, null < value returns UNKNOWN (null) @@ -645,7 +625,7 @@ describe(`evaluators`, () => { }) it(`handles lt with undefined (3-valued logic)`, () => { - const func = new Func(`lt`, [new Value(undefined), new Value(5)]) + const func = lt(new Value(undefined), new Value(5)) const compiled = compileExpression(func) // In 3-valued logic, undefined < value returns UNKNOWN (null) @@ -653,7 +633,7 @@ describe(`evaluators`, () => { }) it(`handles lt with valid values`, () => { - const func = new Func(`lt`, [new Value(3), new Value(5)]) + const func = lt(new Value(3), new Value(5)) const compiled = compileExpression(func) expect(compiled({})).toBe(true) @@ -662,7 +642,7 @@ describe(`evaluators`, () => { describe(`lte (less than or equal)`, () => { it(`handles lte with null (3-valued logic)`, () => { - const func = new Func(`lte`, [new Value(null), new Value(5)]) + const func = lte(new Value(null), new Value(5)) const compiled = compileExpression(func) // In 3-valued logic, null <= value returns UNKNOWN (null) @@ -670,7 +650,7 @@ describe(`evaluators`, () => { }) it(`handles lte with undefined (3-valued logic)`, () => { - const func = new Func(`lte`, [new Value(undefined), new Value(5)]) + const func = lte(new Value(undefined), new Value(5)) const compiled = compileExpression(func) // In 3-valued logic, undefined <= value returns UNKNOWN (null) @@ -681,17 +661,14 @@ describe(`evaluators`, () => { describe(`boolean operators`, () => { it(`handles and with short-circuit evaluation`, () => { - const func = new Func(`and`, [ - new Value(false), - new Func(`divide`, [new Value(1), new Value(0)]), // This would return null, but shouldn't be evaluated - ]) + const func = and(new Value(false), divide(new Value(1), new Value(0))) // This would return null, but shouldn't be evaluated const compiled = compileExpression(func) expect(compiled({})).toBe(false) }) it(`handles and with null value (3-valued logic)`, () => { - const func = new Func(`and`, [new Value(true), new Value(null)]) + const func = and(new Value(true), new Value(null)) const compiled = compileExpression(func) // In 3-valued logic, true AND null = null (UNKNOWN) @@ -699,7 +676,7 @@ describe(`evaluators`, () => { }) it(`handles and with undefined value (3-valued logic)`, () => { - const func = new Func(`and`, [new Value(true), new Value(undefined)]) + const func = and(new Value(true), new Value(undefined)) const compiled = compileExpression(func) // In 3-valued logic, true AND undefined = null (UNKNOWN) @@ -707,7 +684,7 @@ describe(`evaluators`, () => { }) it(`handles and with null and false (3-valued logic)`, () => { - const func = new Func(`and`, [new Value(null), new Value(false)]) + const func = and(new Value(null), new Value(false)) const compiled = compileExpression(func) // In 3-valued logic, null AND false = false @@ -715,28 +692,21 @@ describe(`evaluators`, () => { }) it(`handles and with all true values`, () => { - const func = new Func(`and`, [new Value(true), new Value(true)]) + const func = and(new Value(true), new Value(true)) const compiled = compileExpression(func) expect(compiled({})).toBe(true) }) it(`handles or with short-circuit evaluation`, () => { - const func = new Func(`or`, [ - new Value(true), - new Func(`divide`, [new Value(1), new Value(0)]), // This would return null, but shouldn't be evaluated - ]) + const func = or(new Value(true), divide(new Value(1), new Value(0))) // This would return null, but shouldn't be evaluated const compiled = compileExpression(func) expect(compiled({})).toBe(true) }) it(`handles or with null value (3-valued logic)`, () => { - const func = new Func(`or`, [ - new Value(false), - new Value(0), - new Value(null), - ]) + const func = or(new Value(false), new Value(0), new Value(null)) const compiled = compileExpression(func) // In 3-valued logic, false OR null = null (UNKNOWN) @@ -744,18 +714,14 @@ describe(`evaluators`, () => { }) it(`handles or with undefined value (3-valued logic)`, () => { - const func = new Func(`or`, [ - new Value(false), - new Value(0), - new Value(undefined), - ]) + const func = or(new Value(false), new Value(0), new Value(undefined)) const compiled = compileExpression(func) // In 3-valued logic, false OR undefined = null (UNKNOWN) expect(compiled({})).toBe(null) }) it(`handles or with null and true (3-valued logic)`, () => { - const func = new Func(`or`, [new Value(null), new Value(true)]) + const func = or(new Value(null), new Value(true)) const compiled = compileExpression(func) // In 3-valued logic, null OR true = true @@ -763,14 +729,14 @@ describe(`evaluators`, () => { }) it(`handles or with all false values`, () => { - const func = new Func(`or`, [new Value(false), new Value(0)]) + const func = or(new Value(false), new Value(0)) const compiled = compileExpression(func) expect(compiled({})).toBe(false) }) it(`handles not with null value (3-valued logic)`, () => { - const func = new Func(`not`, [new Value(null)]) + const func = not(new Value(null)) const compiled = compileExpression(func) // In 3-valued logic, NOT null = null (UNKNOWN) @@ -778,7 +744,7 @@ describe(`evaluators`, () => { }) it(`handles not with undefined value (3-valued logic)`, () => { - const func = new Func(`not`, [new Value(undefined)]) + const func = not(new Value(undefined)) const compiled = compileExpression(func) // In 3-valued logic, NOT undefined = null (UNKNOWN) @@ -786,14 +752,14 @@ describe(`evaluators`, () => { }) it(`handles not with true value`, () => { - const func = new Func(`not`, [new Value(true)]) + const func = not(new Value(true)) const compiled = compileExpression(func) expect(compiled({})).toBe(false) }) it(`handles not with false value`, () => { - const func = new Func(`not`, [new Value(false)]) + const func = not(new Value(false)) const compiled = compileExpression(func) expect(compiled({})).toBe(true) diff --git a/packages/db/tests/query/compiler/select.test.ts b/packages/db/tests/query/compiler/select.test.ts index 820209b09..d83748669 100644 --- a/packages/db/tests/query/compiler/select.test.ts +++ b/packages/db/tests/query/compiler/select.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from 'vitest' import { processArgument } from '../../../src/query/compiler/select.js' -import { Aggregate, Func, PropRef, Value } from '../../../src/query/ir.js' +import { Aggregate, PropRef, Value } from '../../../src/query/ir.js' +import { + add, + and, + concat, + gt, + length, + upper, +} from '../../../src/query/builder/operators/index.js' describe(`select compiler`, () => { // Note: Most of the select compilation logic is tested through the full integration @@ -25,7 +33,7 @@ describe(`select compiler`, () => { }) it(`processes function expressions correctly`, () => { - const arg = new Func(`upper`, [new Value(`hello`)]) + const arg = upper(new Value(`hello`)) const namespacedRow = {} const result = processArgument(arg, namespacedRow) @@ -69,7 +77,7 @@ describe(`select compiler`, () => { }) it(`processes function expressions with references`, () => { - const arg = new Func(`length`, [new PropRef([`users`, `name`])]) + const arg = length(new PropRef([`users`, `name`])) const namespacedRow = { users: { name: `Alice` } } const result = processArgument(arg, namespacedRow) @@ -77,11 +85,11 @@ describe(`select compiler`, () => { }) it(`processes function expressions with multiple arguments`, () => { - const arg = new Func(`concat`, [ + const arg = concat( new PropRef([`users`, `firstName`]), new Value(` `), new PropRef([`users`, `lastName`]), - ]) + ) const namespacedRow = { users: { firstName: `John`, @@ -126,7 +134,7 @@ describe(`select compiler`, () => { }) it(`processes boolean function expressions`, () => { - const arg = new Func(`and`, [new Value(true), new Value(false)]) + const arg = and(new Value(true), new Value(false)) const namespacedRow = {} const result = processArgument(arg, namespacedRow) @@ -134,7 +142,7 @@ describe(`select compiler`, () => { }) it(`processes comparison function expressions`, () => { - const arg = new Func(`gt`, [new PropRef([`users`, `age`]), new Value(18)]) + const arg = gt(new PropRef([`users`, `age`]), new Value(18)) const namespacedRow = { users: { age: 25 } } const result = processArgument(arg, namespacedRow) @@ -142,10 +150,10 @@ describe(`select compiler`, () => { }) it(`processes mathematical function expressions`, () => { - const arg = new Func(`add`, [ + const arg = add( new PropRef([`order`, `subtotal`]), new PropRef([`order`, `tax`]), - ]) + ) const namespacedRow = { order: { subtotal: 100, @@ -192,8 +200,8 @@ describe(`select compiler`, () => { const nonAggregateExpressions = [ new PropRef([`users`, `name`]), new Value(42), - new Func(`upper`, [new Value(`hello`)]), - new Func(`length`, [new PropRef([`users`, `name`])]), + upper(new Value(`hello`)), + length(new PropRef([`users`, `name`])), ] const namespacedRow = { users: { name: `John` } } diff --git a/packages/db/tests/query/group-by.test.ts b/packages/db/tests/query/group-by.test.ts index d31da8ae6..ab38d58e5 100644 --- a/packages/db/tests/query/group-by.test.ts +++ b/packages/db/tests/query/group-by.test.ts @@ -5,6 +5,7 @@ import { mockSyncCollectionOptions } from '../utils.js' import { and, avg, + collect, count, eq, gt, @@ -12,7 +13,9 @@ import { isUndefined, lt, max, + maxStr, min, + minStr, not, or, sum, @@ -1720,6 +1723,265 @@ function createGroupByTests(autoIndex: `off` | `eager`): void { }) }) + describe(`collect aggregate`, () => { + let ordersCollection: ReturnType + + beforeEach(() => { + ordersCollection = createOrdersCollection(autoIndex) + }) + + test(`collects values into an array`, () => { + const statusOrders = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ orders: ordersCollection }) + .groupBy(({ orders }) => orders.status) + .select(({ orders }) => ({ + status: orders.status, + order_ids: collect(orders.id), + amounts: collect(orders.amount), + })), + }) + + const completed = statusOrders.get(`completed`) + expect(completed).toBeDefined() + expect(completed?.order_ids).toHaveLength(4) + expect(completed?.order_ids).toContain(1) + expect(completed?.order_ids).toContain(2) + expect(completed?.order_ids).toContain(4) + expect(completed?.order_ids).toContain(7) + expect(completed?.amounts).toEqual( + expect.arrayContaining([100, 200, 300, 400]), + ) + + const pending = statusOrders.get(`pending`) + expect(pending?.order_ids).toHaveLength(2) + expect(pending?.order_ids).toContain(3) + expect(pending?.order_ids).toContain(5) + }) + + test(`updates collected array on insert and delete`, () => { + const categoryOrders = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ orders: ordersCollection }) + .groupBy(({ orders }) => orders.product_category) + .select(({ orders }) => ({ + product_category: orders.product_category, + order_ids: collect(orders.id), + })), + }) + + const initialBooks = categoryOrders.get(`books`) + expect(initialBooks?.order_ids).toHaveLength(3) + + // Insert new order + const newOrder: Order = { + id: 100, + customer_id: 1, + amount: 50, + status: `pending`, + date: new Date(`2023-04-01`), + product_category: `books`, + quantity: 1, + discount: 0, + sales_rep_id: 1, + } + + ordersCollection.utils.begin() + ordersCollection.utils.write({ type: `insert`, value: newOrder }) + ordersCollection.utils.commit() + + const afterInsert = categoryOrders.get(`books`) + expect(afterInsert?.order_ids).toHaveLength(4) + expect(afterInsert?.order_ids).toContain(100) + + // Delete the new order + ordersCollection.utils.begin() + ordersCollection.utils.write({ type: `delete`, value: newOrder }) + ordersCollection.utils.commit() + + const afterDelete = categoryOrders.get(`books`) + expect(afterDelete?.order_ids).toHaveLength(3) + expect(afterDelete?.order_ids).not.toContain(100) + }) + }) + + describe(`minStr and maxStr aggregates`, () => { + type Event = { + id: number + userId: number + timestamp: string + name: string + } + + const sampleEvents: Array = [ + { id: 1, userId: 1, timestamp: `2023-01-15T10:00:00Z`, name: `login` }, + { id: 2, userId: 1, timestamp: `2023-01-15T11:30:00Z`, name: `click` }, + { id: 3, userId: 1, timestamp: `2023-01-15T09:00:00Z`, name: `load` }, + { id: 4, userId: 2, timestamp: `2023-02-01T14:00:00Z`, name: `login` }, + { id: 5, userId: 2, timestamp: `2023-01-20T08:00:00Z`, name: `click` }, + ] + + function createEventsCollection() { + return createCollection( + mockSyncCollectionOptions({ + id: `test-events`, + getKey: (event) => event.id, + initialData: sampleEvents, + autoIndex, + }), + ) + } + + test(`minStr and maxStr preserve string comparison`, () => { + const eventsCollection = createEventsCollection() + + const userEventTimes = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ events: eventsCollection }) + .groupBy(({ events }) => events.userId) + .select(({ events }) => ({ + userId: events.userId, + firstEvent: minStr(events.timestamp), + lastEvent: maxStr(events.timestamp), + })), + }) + + // User 1: timestamps 09:00, 10:00, 11:30 + const user1 = userEventTimes.get(1) + expect(user1?.firstEvent).toBe(`2023-01-15T09:00:00Z`) + expect(user1?.lastEvent).toBe(`2023-01-15T11:30:00Z`) + + // User 2: timestamps 2023-01-20 and 2023-02-01 + const user2 = userEventTimes.get(2) + expect(user2?.firstEvent).toBe(`2023-01-20T08:00:00Z`) + expect(user2?.lastEvent).toBe(`2023-02-01T14:00:00Z`) + }) + + test(`minStr and maxStr work with non-date strings`, () => { + const eventsCollection = createEventsCollection() + + const userEventNames = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ events: eventsCollection }) + .groupBy(({ events }) => events.userId) + .select(({ events }) => ({ + userId: events.userId, + firstName: minStr(events.name), + lastName: maxStr(events.name), + })), + }) + + // User 1: names click, load, login -> alphabetically: click, load, login + const user1 = userEventNames.get(1) + expect(user1?.firstName).toBe(`click`) + expect(user1?.lastName).toBe(`login`) + + // User 2: names click, login + const user2 = userEventNames.get(2) + expect(user2?.firstName).toBe(`click`) + expect(user2?.lastName).toBe(`login`) + }) + + test(`min coerces strings to numbers, minStr does not`, () => { + type Item = { + id: number + group: string + code: string + } + + const items: Array = [ + { id: 1, group: `A`, code: `100` }, + { id: 2, group: `A`, code: `20` }, + { id: 3, group: `A`, code: `3` }, + ] + + const itemsCollection = createCollection( + mockSyncCollectionOptions({ + id: `test-items`, + getKey: (item) => item.id, + initialData: items, + autoIndex, + }), + ) + + const comparison = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ items: itemsCollection }) + .groupBy(({ items }) => items.group) + .select(({ items }) => ({ + group: items.group, + minNumeric: min(items.code), + minString: minStr(items.code), + maxNumeric: max(items.code), + maxString: maxStr(items.code), + })), + }) + + const groupA = comparison.get(`A`) + + // min/max coerce to numbers: 3 < 20 < 100 + expect(groupA?.minNumeric).toBe(3) + expect(groupA?.maxNumeric).toBe(100) + + // minStr/maxStr use string comparison: "100" < "20" < "3" + expect(groupA?.minString).toBe(`100`) + expect(groupA?.maxString).toBe(`3`) + }) + + test(`minStr and maxStr handle live updates`, () => { + const eventsCollection = createEventsCollection() + + const userEventTimes = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ events: eventsCollection }) + .groupBy(({ events }) => events.userId) + .select(({ events }) => ({ + userId: events.userId, + firstEvent: minStr(events.timestamp), + lastEvent: maxStr(events.timestamp), + })), + }) + + const initialUser1 = userEventTimes.get(1) + expect(initialUser1?.firstEvent).toBe(`2023-01-15T09:00:00Z`) + + // Add earlier event + const earlierEvent: Event = { + id: 10, + userId: 1, + timestamp: `2023-01-01T00:00:00Z`, + name: `earlier`, + } + + eventsCollection.utils.begin() + eventsCollection.utils.write({ type: `insert`, value: earlierEvent }) + eventsCollection.utils.commit() + + const afterInsert = userEventTimes.get(1) + expect(afterInsert?.firstEvent).toBe(`2023-01-01T00:00:00Z`) + + // Remove the earlier event + eventsCollection.utils.begin() + eventsCollection.utils.write({ type: `delete`, value: earlierEvent }) + eventsCollection.utils.commit() + + const afterDelete = userEventTimes.get(1) + expect(afterDelete?.firstEvent).toBe(`2023-01-15T09:00:00Z`) + }) + }) + describe(`ORDER BY and HAVING with SELECT fields`, () => { let sessionsCollection: ReturnType