diff --git a/packages/db-ivm/src/operators/groupBy.ts b/packages/db-ivm/src/operators/groupBy.ts index 344c4b1c..0b428e5a 100644 --- a/packages/db-ivm/src/operators/groupBy.ts +++ b/packages/db-ivm/src/operators/groupBy.ts @@ -211,19 +211,19 @@ export function avg( * Creates a min aggregate function that computes the minimum value in a group * @param valueExtractor Function to extract a numeric value from each data entry */ -export function min( - valueExtractor: (value: T) => number = (v) => v as unknown as number -): AggregateFunction { +export function min( + valueExtractor: (value: T) => V = (v) => v as unknown as V +): AggregateFunction { return { preMap: (data: T) => valueExtractor(data), - reduce: (values: Array<[number, number]>) => { - let minValue = Number.POSITIVE_INFINITY + reduce: (values) => { + let minValue = Number.POSITIVE_INFINITY as V for (const [value, _multiplicity] of values) { - if (value < minValue) { + if (Number(value) < Number(minValue)) { minValue = value } } - return minValue === Number.POSITIVE_INFINITY ? 0 : minValue + return minValue === Number.POSITIVE_INFINITY ? (0 as V) : minValue }, } } @@ -232,19 +232,19 @@ export function min( * Creates a max aggregate function that computes the maximum value in a group * @param valueExtractor Function to extract a numeric value from each data entry */ -export function max( - valueExtractor: (value: T) => number = (v) => v as unknown as number -): AggregateFunction { +export function max( + valueExtractor: (value: T) => V = (v) => v as unknown as V +): AggregateFunction { return { preMap: (data: T) => valueExtractor(data), - reduce: (values: Array<[number, number]>) => { - let maxValue = Number.NEGATIVE_INFINITY + reduce: (values) => { + let maxValue = Number.NEGATIVE_INFINITY as V for (const [value, _multiplicity] of values) { - if (value > maxValue) { + if (Number(value) > Number(maxValue)) { maxValue = value } } - return maxValue === Number.NEGATIVE_INFINITY ? 0 : maxValue + return maxValue === Number.NEGATIVE_INFINITY ? (0 as V) : maxValue }, } } diff --git a/packages/db-ivm/tests/operators/groupBy.test.ts b/packages/db-ivm/tests/operators/groupBy.test.ts index 5f20b8d0..e3663bfa 100644 --- a/packages/db-ivm/tests/operators/groupBy.test.ts +++ b/packages/db-ivm/tests/operators/groupBy.test.ts @@ -454,6 +454,7 @@ describe(`Operators`, () => { const input = graph.newInput<{ category: string amount: number + date: Date }>() let latestMessage: any = null @@ -461,6 +462,8 @@ describe(`Operators`, () => { groupBy((data) => ({ category: data.category }), { minimum: min((data) => data.amount), maximum: max((data) => data.amount), + min_date: min((data) => data.date), + max_date: max((data) => data.date), }), output((message) => { latestMessage = message @@ -472,11 +475,11 @@ describe(`Operators`, () => { // Initial data input.sendData( new MultiSet([ - [{ category: `A`, amount: 10 }, 1], - [{ category: `A`, amount: 20 }, 1], - [{ category: `A`, amount: 5 }, 1], - [{ category: `B`, amount: 30 }, 1], - [{ category: `B`, amount: 15 }, 1], + [{ category: `A`, amount: 10, date: new Date(`2025/12/13`) }, 1], + [{ category: `A`, amount: 20, date: new Date(`2025/12/15`) }, 1], + [{ category: `A`, amount: 5, date: new Date(`2025/12/12`) }, 1], + [{ category: `B`, amount: 30, date: new Date(`2025/12/12`) }, 1], + [{ category: `B`, amount: 15, date: new Date(`2025/12/13`) }, 1], ]) ) @@ -493,6 +496,8 @@ describe(`Operators`, () => { category: `A`, minimum: 5, maximum: 20, + min_date: new Date(`2025/12/12`), + max_date: new Date(`2025/12/15`), }, ], 1, @@ -504,6 +509,8 @@ describe(`Operators`, () => { category: `B`, minimum: 15, maximum: 30, + min_date: new Date(`2025/12/12`), + max_date: new Date(`2025/12/13`), }, ], 1, diff --git a/packages/db/src/indexes/btree-index.ts b/packages/db/src/indexes/btree-index.ts index 07edf700..68ca43a6 100644 --- a/packages/db/src/indexes/btree-index.ts +++ b/packages/db/src/indexes/btree-index.ts @@ -4,6 +4,21 @@ import { BaseIndex } from "./base-index.js" import type { BasicExpression } from "../query/ir.js" import type { IndexOperation } from "./base-index.js" +/** + * Normalizes values for use as Map keys to ensure proper equality comparison + * For Date objects, uses timestamp. For other objects, uses JSON serialization. + */ +function normalizeMapKey(value: any): any { + if (value instanceof Date) { + return value.getTime() + } + if (typeof value === `object` && value !== null) { + // For other objects, use JSON serialization as a fallback + // This ensures objects with same content are treated as equal + return JSON.stringify(value) + } + return value +} /** * Options for Ordered index */ @@ -71,14 +86,17 @@ export class BTreeIndex< ) } + // Normalize the value for Map key usage + const normalizedValue = normalizeMapKey(indexedValue) + // Check if this value already exists - if (this.valueMap.has(indexedValue)) { + if (this.valueMap.has(normalizedValue)) { // Add to existing set - this.valueMap.get(indexedValue)!.add(key) + this.valueMap.get(normalizedValue)!.add(key) } else { // Create new set for this value const keySet = new Set([key]) - this.valueMap.set(indexedValue, keySet) + this.valueMap.set(normalizedValue, keySet) this.orderedEntries.set(indexedValue, undefined) } @@ -101,13 +119,16 @@ export class BTreeIndex< return } - if (this.valueMap.has(indexedValue)) { - const keySet = this.valueMap.get(indexedValue)! + // Normalize the value for Map key usage + const normalizedValue = normalizeMapKey(indexedValue) + + if (this.valueMap.has(normalizedValue)) { + const keySet = this.valueMap.get(normalizedValue)! keySet.delete(key) // If set is now empty, remove the entry entirely if (keySet.size === 0) { - this.valueMap.delete(indexedValue) + this.valueMap.delete(normalizedValue) // Remove from ordered entries this.orderedEntries.delete(indexedValue) @@ -195,7 +216,8 @@ export class BTreeIndex< * Performs an equality lookup */ equalityLookup(value: any): Set { - return new Set(this.valueMap.get(value) ?? []) + const normalizedValue = normalizeMapKey(value) + return new Set(this.valueMap.get(normalizedValue) ?? []) } /** @@ -266,7 +288,8 @@ export class BTreeIndex< const result = new Set() for (const value of values) { - const keys = this.valueMap.get(value) + const normalizedValue = normalizeMapKey(value) + const keys = this.valueMap.get(normalizedValue) if (keys) { keys.forEach((key) => result.add(key)) } diff --git a/packages/db/src/query/builder/functions.ts b/packages/db/src/query/builder/functions.ts index b5902bd6..4e1a3350 100644 --- a/packages/db/src/query/builder/functions.ts +++ b/packages/db/src/query/builder/functions.ts @@ -246,23 +246,15 @@ export function sum( return new Aggregate(`sum`, [toExpression(arg)]) } -export function min( - arg: - | RefProxy - | RefProxy - | number - | BasicExpression -): Aggregate { +export function min( + arg: RefProxy | RefProxy | T | BasicExpression +): Aggregate { return new Aggregate(`min`, [toExpression(arg)]) } -export function max( - arg: - | RefProxy - | RefProxy - | number - | BasicExpression -): Aggregate { +export function max( + arg: RefProxy | RefProxy | T | BasicExpression +): Aggregate { return new Aggregate(`max`, [toExpression(arg)]) } diff --git a/packages/db/src/query/compiler/evaluators.ts b/packages/db/src/query/compiler/evaluators.ts index cbeb6ae9..2b355e8f 100644 --- a/packages/db/src/query/compiler/evaluators.ts +++ b/packages/db/src/query/compiler/evaluators.ts @@ -142,8 +142,14 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any { const argA = compiledArgs[0]! const argB = compiledArgs[1]! return (data) => { - const a = argA(data) - const b = argB(data) + let a = argA(data) + let b = argB(data) + if (a instanceof Date) { + a = a.getTime() + } + if (b instanceof Date) { + b = b.getTime() + } return a === b } } diff --git a/packages/db/src/query/compiler/group-by.ts b/packages/db/src/query/compiler/group-by.ts index 8366eb00..9069da32 100644 --- a/packages/db/src/query/compiler/group-by.ts +++ b/packages/db/src/query/compiler/group-by.ts @@ -349,6 +349,20 @@ function getAggregateFunction(aggExpr: Aggregate) { 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) + // Ensure we return a number for numeric aggregate functions + return typeof value === `number` || value instanceof Date + ? value + : value != null + ? Number(value) + : 0 + } + // Return the appropriate aggregate function switch (aggExpr.name.toLowerCase()) { case `sum`: @@ -358,9 +372,9 @@ function getAggregateFunction(aggExpr: Aggregate) { case `avg`: return avg(valueExtractor) case `min`: - return min(valueExtractor) + return min(valueExtractorWithDate) case `max`: - return max(valueExtractor) + return max(valueExtractorWithDate) default: throw new UnsupportedAggregateFunctionError(aggExpr.name) } diff --git a/packages/db/tests/query/group-by.test-d.ts b/packages/db/tests/query/group-by.test-d.ts index 15e3b570..de2a2ae9 100644 --- a/packages/db/tests/query/group-by.test-d.ts +++ b/packages/db/tests/query/group-by.test-d.ts @@ -23,6 +23,7 @@ type Order = { amount: number status: string date: string + date_instance: Date product_category: string quantity: number discount: number @@ -37,6 +38,7 @@ const sampleOrders: Array = [ amount: 100, status: `completed`, date: `2023-01-01`, + date_instance: new Date(`2023-01-01`), product_category: `electronics`, quantity: 2, discount: 0, @@ -48,6 +50,7 @@ const sampleOrders: Array = [ amount: 200, status: `completed`, date: `2023-01-15`, + date_instance: new Date(`2023-01-15`), product_category: `electronics`, quantity: 1, discount: 10, @@ -81,6 +84,8 @@ describe(`Query GROUP BY Types`, () => { avg_amount: avg(orders.amount), min_amount: min(orders.amount), max_amount: max(orders.amount), + min_date: min(orders.date_instance), + max_date: max(orders.date_instance), })), }) @@ -93,6 +98,8 @@ describe(`Query GROUP BY Types`, () => { avg_amount: number min_amount: number max_amount: number + min_date: Date + max_date: Date } | undefined >() diff --git a/packages/db/tests/query/group-by.test.ts b/packages/db/tests/query/group-by.test.ts index 684eb472..34e5a3b5 100644 --- a/packages/db/tests/query/group-by.test.ts +++ b/packages/db/tests/query/group-by.test.ts @@ -23,6 +23,7 @@ type Order = { amount: number status: string date: string + date_instance: Date product_category: string quantity: number discount: number @@ -37,6 +38,7 @@ const sampleOrders: Array = [ amount: 100, status: `completed`, date: `2023-01-01`, + date_instance: new Date(`2023-01-01`), product_category: `electronics`, quantity: 2, discount: 0, @@ -48,6 +50,7 @@ const sampleOrders: Array = [ amount: 200, status: `completed`, date: `2023-01-15`, + date_instance: new Date(`2023-01-15`), product_category: `electronics`, quantity: 1, discount: 10, @@ -59,6 +62,7 @@ const sampleOrders: Array = [ amount: 150, status: `pending`, date: `2023-01-20`, + date_instance: new Date(`2023-01-20`), product_category: `books`, quantity: 3, discount: 5, @@ -70,6 +74,7 @@ const sampleOrders: Array = [ amount: 300, status: `completed`, date: `2023-02-01`, + date_instance: new Date(`2023-02-01`), product_category: `electronics`, quantity: 1, discount: 0, @@ -81,6 +86,7 @@ const sampleOrders: Array = [ amount: 250, status: `pending`, date: `2023-02-10`, + date_instance: new Date(`2023-02-10`), product_category: `books`, quantity: 5, discount: 15, @@ -92,6 +98,7 @@ const sampleOrders: Array = [ amount: 75, status: `cancelled`, date: `2023-02-15`, + date_instance: new Date(`2023-02-15`), product_category: `electronics`, quantity: 1, discount: 0, @@ -103,6 +110,7 @@ const sampleOrders: Array = [ amount: 400, status: `completed`, date: `2023-03-01`, + date_instance: new Date(`2023-03-01`), product_category: `books`, quantity: 2, discount: 20, @@ -144,6 +152,8 @@ function createGroupByTests(autoIndex: `off` | `eager`): void { avg_amount: avg(orders.amount), min_amount: min(orders.amount), max_amount: max(orders.amount), + min_date: min(orders.date_instance), + max_date: max(orders.date_instance), })), }) @@ -158,6 +168,12 @@ function createGroupByTests(autoIndex: `off` | `eager`): void { expect(customer1?.avg_amount).toBe(233.33333333333334) // (100+200+400)/3 expect(customer1?.min_amount).toBe(100) expect(customer1?.max_amount).toBe(400) + expect(customer1?.min_date.toISOString()).toBe( + new Date(`2023-01-01`).toISOString() + ) + expect(customer1?.max_date.toISOString()).toBe( + new Date(`2023-03-01`).toISOString() + ) // Customer 2: orders 3, 4 (amounts: 150, 300) const customer2 = customerSummary.get(2) @@ -168,6 +184,12 @@ function createGroupByTests(autoIndex: `off` | `eager`): void { expect(customer2?.avg_amount).toBe(225) // (150+300)/2 expect(customer2?.min_amount).toBe(150) expect(customer2?.max_amount).toBe(300) + expect(customer2?.min_date.toISOString()).toBe( + new Date(`2023-01-20`).toISOString() + ) + expect(customer2?.max_date.toISOString()).toBe( + new Date(`2023-02-01`).toISOString() + ) // Customer 3: orders 5, 6 (amounts: 250, 75) const customer3 = customerSummary.get(3) @@ -178,6 +200,12 @@ function createGroupByTests(autoIndex: `off` | `eager`): void { expect(customer3?.avg_amount).toBe(162.5) // (250+75)/2 expect(customer3?.min_amount).toBe(75) expect(customer3?.max_amount).toBe(250) + expect(customer3?.min_date.toISOString()).toBe( + new Date(`2023-02-10`).toISOString() + ) + expect(customer3?.max_date.toISOString()).toBe( + new Date(`2023-02-15`).toISOString() + ) }) test(`group by status`, () => { @@ -603,9 +631,14 @@ function createGroupByTests(autoIndex: `off` | `eager`): void { min_amount: min(orders.amount), max_amount: max(orders.amount), spending_range: max(orders.amount), // We'll calculate range in the filter + last_date: max(orders.date_instance), })) .having(({ orders }) => - and(gte(min(orders.amount), 75), gte(max(orders.amount), 300)) + and( + gte(min(orders.amount), 75), + gte(max(orders.amount), 300), + gte(max(orders.date_instance), new Date(`2020-09-17`)) + ) ), }) @@ -698,6 +731,7 @@ function createGroupByTests(autoIndex: `off` | `eager`): void { amount: 500, status: `completed`, date: `2023-03-15`, + date_instance: new Date(`2023-03-15`), product_category: `electronics`, quantity: 2, discount: 0, @@ -719,6 +753,7 @@ function createGroupByTests(autoIndex: `off` | `eager`): void { amount: 350, status: `pending`, date: `2023-03-20`, + date_instance: new Date(`2023-03-20`), product_category: `books`, quantity: 1, discount: 5, @@ -900,6 +935,7 @@ function createGroupByTests(autoIndex: `off` | `eager`): void { amount: 100, status: `completed`, date: `2023-01-01`, + date_instance: new Date(`2023-01-01`), product_category: `electronics`, quantity: 1, discount: 0, diff --git a/packages/db/tests/query/where.test.ts b/packages/db/tests/query/where.test.ts index 8dc673f3..e1114243 100644 --- a/packages/db/tests/query/where.test.ts +++ b/packages/db/tests/query/where.test.ts @@ -29,6 +29,7 @@ type Employee = { salary: number active: boolean hire_date: string + hire_date_instance: Date email: string | null first_name: string last_name: string @@ -44,6 +45,7 @@ const sampleEmployees: Array = [ salary: 75000, active: true, hire_date: `2020-01-15`, + hire_date_instance: new Date(`2020-01-15`), email: `alice@company.com`, first_name: `Alice`, last_name: `Johnson`, @@ -55,7 +57,8 @@ const sampleEmployees: Array = [ department_id: 2, salary: 65000, active: true, - hire_date: `2019-03-20`, + hire_date: `2020-01-15`, + hire_date_instance: new Date(`2020-01-15`), email: `bob@company.com`, first_name: `Bob`, last_name: `Smith`, @@ -68,6 +71,7 @@ const sampleEmployees: Array = [ salary: 85000, active: false, hire_date: `2018-07-10`, + hire_date_instance: new Date(`2018-07-10`), email: null, first_name: `Charlie`, last_name: `Brown`, @@ -80,6 +84,7 @@ const sampleEmployees: Array = [ salary: 95000, active: true, hire_date: `2021-11-05`, + hire_date_instance: new Date(`2021-11-05`), email: `diana@company.com`, first_name: `Diana`, last_name: `Miller`, @@ -92,6 +97,7 @@ const sampleEmployees: Array = [ salary: 55000, active: true, hire_date: `2022-02-14`, + hire_date_instance: new Date(`2022-02-14`), email: `eve@company.com`, first_name: `Eve`, last_name: `Wilson`, @@ -104,6 +110,7 @@ const sampleEmployees: Array = [ salary: 45000, active: false, hire_date: `2017-09-30`, + hire_date_instance: new Date(`2017-09-30`), email: `frank@company.com`, first_name: `Frank`, last_name: `Davis`, @@ -132,6 +139,22 @@ function createWhereTests(autoIndex: `off` | `eager`): void { }) test(`eq operator - equality comparison`, () => { + const hireDateEmployee = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ emp: employeesCollection }) + .where(({ emp }) => + eq(emp.hire_date_instance, new Date(`2020-01-15`)) + ) + .select(({ emp }) => ({ + id: emp.id, + name: emp.name, + active: emp.active, + })), + }) + expect(hireDateEmployee.size).toBe(2) + const activeEmployees = createLiveQueryCollection({ startSync: true, query: (q) => @@ -169,6 +192,7 @@ function createWhereTests(autoIndex: `off` | `eager`): void { salary: 70000, active: true, hire_date: `2023-01-10`, + hire_date_instance: new Date(`2023-01-10`), email: `grace@company.com`, first_name: `Grace`, last_name: `Lee`, @@ -238,6 +262,7 @@ function createWhereTests(autoIndex: `off` | `eager`): void { salary: 80000, // Above 70k threshold active: true, hire_date: `2023-01-15`, + hire_date_instance: new Date(`2023-01-15`), email: `henry@company.com`, first_name: `Henry`, last_name: `Young`, @@ -890,6 +915,7 @@ function createWhereTests(autoIndex: `off` | `eager`): void { salary: 80000, // >= 70k active: true, hire_date: `2023-01-20`, + hire_date_instance: new Date(`2023-01-20`), email: `ian@company.com`, first_name: `Ian`, last_name: `Clark`, @@ -954,6 +980,7 @@ function createWhereTests(autoIndex: `off` | `eager`): void { salary: 60000, active: true, hire_date: `2023-01-25`, + hire_date_instance: new Date(`2023-01-25`), email: `amy@company.com`, first_name: `amy`, // lowercase 'a' last_name: `stone`, @@ -1023,6 +1050,7 @@ function createWhereTests(autoIndex: `off` | `eager`): void { salary: 60000, active: true, hire_date: `2023-02-01`, + hire_date_instance: new Date(`2023-02-01`), email: null, // null email first_name: `Jack`, last_name: `Null`, @@ -1073,6 +1101,7 @@ function createWhereTests(autoIndex: `off` | `eager`): void { salary: 60000, active: true, hire_date: `2023-02-05`, + hire_date_instance: new Date(`2023-02-05`), email: `first@company.com`, first_name: `First`, last_name: `Employee`, @@ -1234,6 +1263,7 @@ function createWhereTests(autoIndex: `off` | `eager`): void { salary: 80000, // >= 70k active: true, // active hire_date: `2023-01-01`, + hire_date_instance: new Date(`2023-01-01`), email: `john@company.com`, first_name: `John`, last_name: `Doe`,