Skip to content

Commit 51c6bc5

Browse files
MAST1999samwillis
andauthored
feat: add support for dates to max and min functions. (#428)
* feat: add support for dates to max and min functions. * feat: enable eq comparison for date and add a tests. * fix: remove unused generic value. * various fixes * changeset * address feedback * fix --------- Co-authored-by: Sam Willis <[email protected]>
1 parent 248e2c6 commit 51c6bc5

File tree

11 files changed

+226
-71
lines changed

11 files changed

+226
-71
lines changed

.changeset/brave-rocks-lie.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@tanstack/db-ivm": patch
3+
"@tanstack/db": patch
4+
---
5+
6+
Add support for Date objects to min/max aggregates and range queries when using an index.

packages/db-ivm/src/operators/groupBy.ts

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -210,44 +210,64 @@ export function avg<T>(
210210
}
211211
}
212212

213+
type CanMinMax = number | Date | bigint
214+
213215
/**
214216
* Creates a min aggregate function that computes the minimum value in a group
215-
* @param valueExtractor Function to extract a numeric value from each data entry
217+
* @param valueExtractor Function to extract a comparable value from each data entry
216218
*/
217-
export function min<T>(
218-
valueExtractor: (value: T) => number = (v) => v as unknown as number
219-
): AggregateFunction<T, number, number> {
219+
export function min<T extends CanMinMax>(): AggregateFunction<
220+
T,
221+
T | undefined,
222+
T | undefined
223+
>
224+
export function min<T, V extends CanMinMax>(
225+
valueExtractor: (value: T) => V
226+
): AggregateFunction<T, V | undefined, V | undefined>
227+
export function min<T, V extends CanMinMax>(
228+
valueExtractor?: (value: T) => V
229+
): AggregateFunction<T, V | undefined, V | undefined> {
230+
const extractor = valueExtractor ?? ((v: T) => v as unknown as V)
220231
return {
221-
preMap: (data: T) => valueExtractor(data),
222-
reduce: (values: Array<[number, number]>) => {
223-
let minValue = Number.POSITIVE_INFINITY
232+
preMap: (data: T) => extractor(data),
233+
reduce: (values) => {
234+
let minValue: V | undefined
224235
for (const [value, _multiplicity] of values) {
225-
if (value < minValue) {
236+
if (!minValue || (value && value < minValue)) {
226237
minValue = value
227238
}
228239
}
229-
return minValue === Number.POSITIVE_INFINITY ? 0 : minValue
240+
return minValue
230241
},
231242
}
232243
}
233244

234245
/**
235246
* Creates a max aggregate function that computes the maximum value in a group
236-
* @param valueExtractor Function to extract a numeric value from each data entry
247+
* @param valueExtractor Function to extract a comparable value from each data entry
237248
*/
238-
export function max<T>(
239-
valueExtractor: (value: T) => number = (v) => v as unknown as number
240-
): AggregateFunction<T, number, number> {
249+
export function max<T extends CanMinMax>(): AggregateFunction<
250+
T,
251+
T | undefined,
252+
T | undefined
253+
>
254+
export function max<T, V extends CanMinMax>(
255+
valueExtractor: (value: T) => V
256+
): AggregateFunction<T, V | undefined, V | undefined>
257+
export function max<T, V extends CanMinMax>(
258+
valueExtractor?: (value: T) => V
259+
): AggregateFunction<T, V | undefined, V | undefined> {
260+
const extractor = valueExtractor ?? ((v: T) => v as unknown as V)
241261
return {
242-
preMap: (data: T) => valueExtractor(data),
243-
reduce: (values: Array<[number, number]>) => {
244-
let maxValue = Number.NEGATIVE_INFINITY
262+
preMap: (data: T) => extractor(data),
263+
reduce: (values) => {
264+
let maxValue: V | undefined
245265
for (const [value, _multiplicity] of values) {
246-
if (value > maxValue) {
266+
if (!maxValue || (value && value > maxValue)) {
247267
maxValue = value
248268
}
249269
}
250-
return maxValue === Number.NEGATIVE_INFINITY ? 0 : maxValue
270+
return maxValue
251271
},
252272
}
253273
}

packages/db-ivm/tests/operators/groupBy.test.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -524,13 +524,16 @@ describe(`Operators`, () => {
524524
const input = graph.newInput<{
525525
category: string
526526
amount: number
527+
date: Date
527528
}>()
528529
let latestMessage: any = null
529530

530531
input.pipe(
531532
groupBy((data) => ({ category: data.category }), {
532533
minimum: min((data) => data.amount),
533534
maximum: max((data) => data.amount),
535+
min_date: min((data) => data.date),
536+
max_date: max((data) => data.date),
534537
}),
535538
output((message) => {
536539
latestMessage = message
@@ -542,11 +545,11 @@ describe(`Operators`, () => {
542545
// Initial data
543546
input.sendData(
544547
new MultiSet([
545-
[{ category: `A`, amount: 10 }, 1],
546-
[{ category: `A`, amount: 20 }, 1],
547-
[{ category: `A`, amount: 5 }, 1],
548-
[{ category: `B`, amount: 30 }, 1],
549-
[{ category: `B`, amount: 15 }, 1],
548+
[{ category: `A`, amount: 10, date: new Date(`2025/12/13`) }, 1],
549+
[{ category: `A`, amount: 20, date: new Date(`2025/12/15`) }, 1],
550+
[{ category: `A`, amount: 5, date: new Date(`2025/12/12`) }, 1],
551+
[{ category: `B`, amount: 30, date: new Date(`2025/12/12`) }, 1],
552+
[{ category: `B`, amount: 15, date: new Date(`2025/12/13`) }, 1],
550553
])
551554
)
552555

@@ -563,6 +566,8 @@ describe(`Operators`, () => {
563566
category: `A`,
564567
minimum: 5,
565568
maximum: 20,
569+
min_date: new Date(`2025/12/12`),
570+
max_date: new Date(`2025/12/15`),
566571
},
567572
],
568573
1,
@@ -574,6 +579,8 @@ describe(`Operators`, () => {
574579
category: `B`,
575580
minimum: 15,
576581
maximum: 30,
582+
min_date: new Date(`2025/12/12`),
583+
max_date: new Date(`2025/12/13`),
577584
},
578585
],
579586
1,

packages/db/src/indexes/btree-index.ts

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { BTree } from "../utils/btree.js"
2-
import { defaultComparator } from "../utils/comparison.js"
2+
import { defaultComparator, normalizeValue } from "../utils/comparison.js"
33
import { BaseIndex } from "./base-index.js"
44
import type { BasicExpression } from "../query/ir.js"
55
import type { IndexOperation } from "./base-index.js"
@@ -71,15 +71,18 @@ export class BTreeIndex<
7171
)
7272
}
7373

74+
// Normalize the value for Map key usage
75+
const normalizedValue = normalizeValue(indexedValue)
76+
7477
// Check if this value already exists
75-
if (this.valueMap.has(indexedValue)) {
78+
if (this.valueMap.has(normalizedValue)) {
7679
// Add to existing set
77-
this.valueMap.get(indexedValue)!.add(key)
80+
this.valueMap.get(normalizedValue)!.add(key)
7881
} else {
7982
// Create new set for this value
8083
const keySet = new Set<TKey>([key])
81-
this.valueMap.set(indexedValue, keySet)
82-
this.orderedEntries.set(indexedValue, undefined)
84+
this.valueMap.set(normalizedValue, keySet)
85+
this.orderedEntries.set(normalizedValue, undefined)
8386
}
8487

8588
this.indexedKeys.add(key)
@@ -101,16 +104,19 @@ export class BTreeIndex<
101104
return
102105
}
103106

104-
if (this.valueMap.has(indexedValue)) {
105-
const keySet = this.valueMap.get(indexedValue)!
107+
// Normalize the value for Map key usage
108+
const normalizedValue = normalizeValue(indexedValue)
109+
110+
if (this.valueMap.has(normalizedValue)) {
111+
const keySet = this.valueMap.get(normalizedValue)!
106112
keySet.delete(key)
107113

108114
// If set is now empty, remove the entry entirely
109115
if (keySet.size === 0) {
110-
this.valueMap.delete(indexedValue)
116+
this.valueMap.delete(normalizedValue)
111117

112118
// Remove from ordered entries
113-
this.orderedEntries.delete(indexedValue)
119+
this.orderedEntries.delete(normalizedValue)
114120
}
115121
}
116122

@@ -195,7 +201,8 @@ export class BTreeIndex<
195201
* Performs an equality lookup
196202
*/
197203
equalityLookup(value: any): Set<TKey> {
198-
return new Set(this.valueMap.get(value) ?? [])
204+
const normalizedValue = normalizeValue(value)
205+
return new Set(this.valueMap.get(normalizedValue) ?? [])
199206
}
200207

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

209-
const fromKey = from ?? this.orderedEntries.minKey()
210-
const toKey = to ?? this.orderedEntries.maxKey()
216+
const normalizedFrom = normalizeValue(from)
217+
const normalizedTo = normalizeValue(to)
218+
const fromKey = normalizedFrom ?? this.orderedEntries.minKey()
219+
const toKey = normalizedTo ?? this.orderedEntries.maxKey()
211220

212221
this.orderedEntries.forRange(
213222
fromKey,
@@ -240,7 +249,7 @@ export class BTreeIndex<
240249
const keysInResult: Set<TKey> = new Set()
241250
const result: Array<TKey> = []
242251
const nextKey = (k?: any) => this.orderedEntries.nextHigherKey(k)
243-
let key = from
252+
let key = normalizeValue(from)
244253

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

268277
for (const value of values) {
269-
const keys = this.valueMap.get(value)
278+
const normalizedValue = normalizeValue(value)
279+
const keys = this.valueMap.get(normalizedValue)
270280
if (keys) {
271281
keys.forEach((key) => result.add(key))
272282
}

packages/db/src/query/builder/functions.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,10 @@ type ExtractType<T> =
5353
// Helper type to determine aggregate return type based on input nullability
5454
type AggregateReturnType<T> =
5555
ExtractType<T> extends infer U
56-
? U extends number | undefined | null
56+
? U extends number | undefined | null | Date | bigint
5757
? Aggregate<U>
58-
: Aggregate<number | undefined | null>
59-
: Aggregate<number | undefined | null>
58+
: Aggregate<number | undefined | null | Date | bigint>
59+
: Aggregate<number | undefined | null | Date | bigint>
6060

6161
// Helper type to determine string function return type based on input nullability
6262
type StringFunctionReturnType<T> =

packages/db/src/query/compiler/evaluators.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
UnknownExpressionTypeError,
44
UnknownFunctionError,
55
} from "../../errors.js"
6+
import { normalizeValue } from "../../utils/comparison.js"
67
import type { BasicExpression, Func, PropRef } from "../ir.js"
78
import type { NamespacedRow } from "../../types.js"
89

@@ -142,8 +143,8 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
142143
const argA = compiledArgs[0]!
143144
const argB = compiledArgs[1]!
144145
return (data) => {
145-
const a = argA(data)
146-
const b = argB(data)
146+
const a = normalizeValue(argA(data))
147+
const b = normalizeValue(argB(data))
147148
return a === b
148149
}
149150
}

packages/db/src/query/compiler/group-by.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,19 @@ function getAggregateFunction(aggExpr: Aggregate) {
349349
return typeof value === `number` ? value : value != null ? Number(value) : 0
350350
}
351351

352+
// Create a value extractor function for the expression to aggregate
353+
const valueExtractorWithDate = ([, namespacedRow]: [
354+
string,
355+
NamespacedRow,
356+
]) => {
357+
const value = compiledExpr(namespacedRow)
358+
return typeof value === `number` || value instanceof Date
359+
? value
360+
: value != null
361+
? Number(value)
362+
: 0
363+
}
364+
352365
// Create a raw value extractor function for the expression to aggregate
353366
const rawValueExtractor = ([, namespacedRow]: [string, NamespacedRow]) => {
354367
return compiledExpr(namespacedRow)
@@ -363,9 +376,9 @@ function getAggregateFunction(aggExpr: Aggregate) {
363376
case `avg`:
364377
return avg(valueExtractor)
365378
case `min`:
366-
return min(valueExtractor)
379+
return min(valueExtractorWithDate)
367380
case `max`:
368-
return max(valueExtractor)
381+
return max(valueExtractorWithDate)
369382
default:
370383
throw new UnsupportedAggregateFunctionError(aggExpr.name)
371384
}

packages/db/src/utils/comparison.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,13 @@ export const defaultComparator = makeComparator({
110110
nulls: `first`,
111111
stringSort: `locale`,
112112
})
113+
114+
/**
115+
* Normalize a value for comparison
116+
*/
117+
export function normalizeValue(value: any): any {
118+
if (value instanceof Date) {
119+
return value.getTime()
120+
}
121+
return value
122+
}

packages/db/tests/query/group-by.test-d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type Order = {
2323
amount: number
2424
status: string
2525
date: string
26+
date_instance: Date
2627
product_category: string
2728
quantity: number
2829
discount: number
@@ -37,6 +38,7 @@ const sampleOrders: Array<Order> = [
3738
amount: 100,
3839
status: `completed`,
3940
date: `2023-01-01`,
41+
date_instance: new Date(`2023-01-01`),
4042
product_category: `electronics`,
4143
quantity: 2,
4244
discount: 0,
@@ -48,6 +50,7 @@ const sampleOrders: Array<Order> = [
4850
amount: 200,
4951
status: `completed`,
5052
date: `2023-01-15`,
53+
date_instance: new Date(`2023-01-15`),
5154
product_category: `electronics`,
5255
quantity: 1,
5356
discount: 10,
@@ -81,6 +84,8 @@ describe(`Query GROUP BY Types`, () => {
8184
avg_amount: avg(orders.amount),
8285
min_amount: min(orders.amount),
8386
max_amount: max(orders.amount),
87+
min_date: min(orders.date_instance),
88+
max_date: max(orders.date_instance),
8489
})),
8590
})
8691

@@ -93,6 +98,8 @@ describe(`Query GROUP BY Types`, () => {
9398
avg_amount: number
9499
min_amount: number
95100
max_amount: number
101+
min_date: Date
102+
max_date: Date
96103
}
97104
| undefined
98105
>()

0 commit comments

Comments
 (0)