Skip to content
6 changes: 6 additions & 0 deletions .changeset/brave-rocks-lie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@tanstack/db-ivm": patch
"@tanstack/db": patch
---

Add support for Date objects to min/max aggregates and range queries when using an index.
56 changes: 38 additions & 18 deletions packages/db-ivm/src/operators/groupBy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,44 +210,64 @@ export function avg<T>(
}
}

type CanMinMax = number | Date | bigint

/**
* 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
* @param valueExtractor Function to extract a comparable value from each data entry
*/
export function min<T>(
valueExtractor: (value: T) => number = (v) => v as unknown as number
): AggregateFunction<T, number, number> {
export function min<T extends CanMinMax>(): AggregateFunction<
T,
T | undefined,
T | undefined
>
export function min<T, V extends CanMinMax>(
valueExtractor: (value: T) => V
): AggregateFunction<T, V | undefined, V | undefined>
export function min<T, V extends CanMinMax>(
valueExtractor?: (value: T) => V
): AggregateFunction<T, V | undefined, V | undefined> {
const extractor = valueExtractor ?? ((v: T) => v as unknown as V)
return {
preMap: (data: T) => valueExtractor(data),
reduce: (values: Array<[number, number]>) => {
let minValue = Number.POSITIVE_INFINITY
preMap: (data: T) => extractor(data),
reduce: (values) => {
let minValue: V | undefined
for (const [value, _multiplicity] of values) {
if (value < minValue) {
if (!minValue || !value || value < minValue) {
minValue = value
}
}
return minValue === Number.POSITIVE_INFINITY ? 0 : minValue
return minValue
},
}
}

/**
* 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
* @param valueExtractor Function to extract a comparable value from each data entry
*/
export function max<T>(
valueExtractor: (value: T) => number = (v) => v as unknown as number
): AggregateFunction<T, number, number> {
export function max<T extends CanMinMax>(): AggregateFunction<
T,
T | undefined,
T | undefined
>
export function max<T, V extends CanMinMax>(
valueExtractor: (value: T) => V
): AggregateFunction<T, V | undefined, V | undefined>
export function max<T, V extends CanMinMax>(
valueExtractor?: (value: T) => V
): AggregateFunction<T, V | undefined, V | undefined> {
const extractor = valueExtractor ?? ((v: T) => v as unknown as V)
return {
preMap: (data: T) => valueExtractor(data),
reduce: (values: Array<[number, number]>) => {
let maxValue = Number.NEGATIVE_INFINITY
preMap: (data: T) => extractor(data),
reduce: (values) => {
let maxValue: V | undefined
for (const [value, _multiplicity] of values) {
if (value > maxValue) {
if (!maxValue || !value || value > maxValue) {
maxValue = value
}
}
return maxValue === Number.NEGATIVE_INFINITY ? 0 : maxValue
return maxValue
},
}
}
Expand Down
17 changes: 12 additions & 5 deletions packages/db-ivm/tests/operators/groupBy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,13 +524,16 @@ describe(`Operators`, () => {
const input = graph.newInput<{
category: string
amount: number
date: Date
}>()
let latestMessage: any = null

input.pipe(
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
Expand All @@ -542,11 +545,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],
])
)

Expand All @@ -563,6 +566,8 @@ describe(`Operators`, () => {
category: `A`,
minimum: 5,
maximum: 20,
min_date: new Date(`2025/12/12`),
max_date: new Date(`2025/12/15`),
},
],
1,
Expand All @@ -574,6 +579,8 @@ describe(`Operators`, () => {
category: `B`,
minimum: 15,
maximum: 30,
min_date: new Date(`2025/12/12`),
max_date: new Date(`2025/12/13`),
},
],
1,
Expand Down
38 changes: 24 additions & 14 deletions packages/db/src/indexes/btree-index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BTree } from "../utils/btree.js"
import { defaultComparator } from "../utils/comparison.js"
import { defaultComparator, normalizeValue } from "../utils/comparison.js"
import { BaseIndex } from "./base-index.js"
import type { BasicExpression } from "../query/ir.js"
import type { IndexOperation } from "./base-index.js"
Expand Down Expand Up @@ -71,15 +71,18 @@ export class BTreeIndex<
)
}

// Normalize the value for Map key usage
const normalizedValue = normalizeValue(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<TKey>([key])
this.valueMap.set(indexedValue, keySet)
this.orderedEntries.set(indexedValue, undefined)
this.valueMap.set(normalizedValue, keySet)
this.orderedEntries.set(normalizedValue, undefined)
}

this.indexedKeys.add(key)
Expand All @@ -101,16 +104,19 @@ export class BTreeIndex<
return
}

if (this.valueMap.has(indexedValue)) {
const keySet = this.valueMap.get(indexedValue)!
// Normalize the value for Map key usage
const normalizedValue = normalizeValue(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)
this.orderedEntries.delete(normalizedValue)
}
}

Expand Down Expand Up @@ -195,7 +201,8 @@ export class BTreeIndex<
* Performs an equality lookup
*/
equalityLookup(value: any): Set<TKey> {
return new Set(this.valueMap.get(value) ?? [])
const normalizedValue = normalizeValue(value)
return new Set(this.valueMap.get(normalizedValue) ?? [])
}

/**
Expand All @@ -206,8 +213,10 @@ export class BTreeIndex<
const { from, to, fromInclusive = true, toInclusive = true } = options
const result = new Set<TKey>()

const fromKey = from ?? this.orderedEntries.minKey()
const toKey = to ?? this.orderedEntries.maxKey()
const normalizedFrom = normalizeValue(from)
const normalizedTo = normalizeValue(to)
const fromKey = normalizedFrom ?? this.orderedEntries.minKey()
const toKey = normalizedTo ?? this.orderedEntries.maxKey()

this.orderedEntries.forRange(
fromKey,
Expand Down Expand Up @@ -240,7 +249,7 @@ export class BTreeIndex<
const keysInResult: Set<TKey> = new Set()
const result: Array<TKey> = []
const nextKey = (k?: any) => this.orderedEntries.nextHigherKey(k)
let key = from
let key = normalizeValue(from)

while ((key = nextKey(key)) && result.length < n) {
const keys = this.valueMap.get(key)
Expand All @@ -266,7 +275,8 @@ export class BTreeIndex<
const result = new Set<TKey>()

for (const value of values) {
const keys = this.valueMap.get(value)
const normalizedValue = normalizeValue(value)
const keys = this.valueMap.get(normalizedValue)
if (keys) {
keys.forEach((key) => result.add(key))
}
Expand Down
6 changes: 3 additions & 3 deletions packages/db/src/query/builder/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ type ExtractType<T> =
// Helper type to determine aggregate return type based on input nullability
type AggregateReturnType<T> =
ExtractType<T> extends infer U
? U extends number | undefined | null
? U extends number | undefined | null | Date | bigint
? Aggregate<U>
: Aggregate<number | undefined | null>
: Aggregate<number | undefined | null>
: Aggregate<number | undefined | null | Date | bigint>
: Aggregate<number | undefined | null | Date | bigint>

// Helper type to determine string function return type based on input nullability
type StringFunctionReturnType<T> =
Expand Down
5 changes: 3 additions & 2 deletions packages/db/src/query/compiler/evaluators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
UnknownExpressionTypeError,
UnknownFunctionError,
} from "../../errors.js"
import { normalizeValue } from "../../utils/comparison.js"
import type { BasicExpression, Func, PropRef } from "../ir.js"
import type { NamespacedRow } from "../../types.js"

Expand Down Expand Up @@ -142,8 +143,8 @@ 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)
const a = normalizeValue(argA(data))
const b = normalizeValue(argB(data))
return a === b
}
}
Expand Down
17 changes: 15 additions & 2 deletions packages/db/src/query/compiler/group-by.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,19 @@ 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)
return typeof value === `number` || value instanceof Date
? value
: value != null
? Number(value)
: 0
}

// Create a raw value extractor function for the expression to aggregate
const rawValueExtractor = ([, namespacedRow]: [string, NamespacedRow]) => {
return compiledExpr(namespacedRow)
Expand All @@ -363,9 +376,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)
}
Expand Down
10 changes: 10 additions & 0 deletions packages/db/src/utils/comparison.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,13 @@ export const defaultComparator = makeComparator({
nulls: `first`,
stringSort: `locale`,
})

/**
* Normalize a value for comparison
*/
export function normalizeValue(value: any): any {
if (value instanceof Date) {
return value.getTime()
}
return value
}
7 changes: 7 additions & 0 deletions packages/db/tests/query/group-by.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type Order = {
amount: number
status: string
date: string
date_instance: Date
product_category: string
quantity: number
discount: number
Expand All @@ -37,6 +38,7 @@ const sampleOrders: Array<Order> = [
amount: 100,
status: `completed`,
date: `2023-01-01`,
date_instance: new Date(`2023-01-01`),
product_category: `electronics`,
quantity: 2,
discount: 0,
Expand All @@ -48,6 +50,7 @@ const sampleOrders: Array<Order> = [
amount: 200,
status: `completed`,
date: `2023-01-15`,
date_instance: new Date(`2023-01-15`),
product_category: `electronics`,
quantity: 1,
discount: 10,
Expand Down Expand Up @@ -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),
})),
})

Expand All @@ -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
>()
Expand Down
Loading
Loading