Skip to content

Commit bdf9405

Browse files
KyleAMathewsclaude
andauthored
Add string support to min/max operators (#1120)
* feat(db): add string support to min/max aggregate functions Add support for string values in min() and max() aggregate functions, using lexicographic comparison (same as SQL behavior). Changes: - Add `string` to CanMinMax type in db-ivm/groupBy.ts - Update AggregateReturnType to include string in db/builder/functions.ts - Update valueExtractor in group-by.ts compiler to preserve strings - Add test case for min/max on string fields * chore: change string min/max changeset to patch Co-Authored-By: Claude Opus 4.5 <[email protected]> * refactor(db): simplify group-by compiler code - Flatten nested ternary in valueExtractor for readability - Remove dead validation code in replaceAggregatesByRefs ref case - Condense docstring to reflect actual behavior Co-Authored-By: Claude Opus 4.5 <[email protected]> --------- Co-authored-by: Claude <[email protected]>
1 parent 1b22e40 commit bdf9405

5 files changed

Lines changed: 80 additions & 58 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@tanstack/db': patch
3+
'@tanstack/db-ivm': patch
4+
---
5+
6+
Add string support to `min()` and `max()` aggregate functions. These functions now work with strings using lexicographic comparison, matching standard SQL behavior.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ export function avg<T>(
211211
}
212212
}
213213

214-
type CanMinMax = number | Date | bigint
214+
type CanMinMax = number | Date | bigint | string
215215

216216
/**
217217
* Creates a min aggregate function that computes the minimum value in a group

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 | Date | bigint
56+
? U extends number | undefined | null | Date | bigint | string
5757
? Aggregate<U>
58-
: Aggregate<number | undefined | null | Date | bigint>
59-
: Aggregate<number | undefined | null | Date | bigint>
58+
: Aggregate<number | undefined | null | Date | bigint | string>
59+
: Aggregate<number | undefined | null | Date | bigint | string>
6060

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

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

Lines changed: 27 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -353,20 +353,28 @@ function getAggregateFunction(aggExpr: Aggregate) {
353353
const valueExtractor = ([, namespacedRow]: [string, NamespacedRow]) => {
354354
const value = compiledExpr(namespacedRow)
355355
// Ensure we return a number for numeric aggregate functions
356-
return typeof value === `number` ? value : value != null ? Number(value) : 0
356+
if (typeof value === `number`) {
357+
return value
358+
}
359+
return value != null ? Number(value) : 0
357360
}
358361

359-
// Create a value extractor function for the expression to aggregate
360-
const valueExtractorWithDate = ([, namespacedRow]: [
362+
// Create a value extractor function for min/max that preserves comparable types
363+
const valueExtractorForMinMax = ([, namespacedRow]: [
361364
string,
362365
NamespacedRow,
363366
]) => {
364367
const value = compiledExpr(namespacedRow)
365-
return typeof value === `number` || value instanceof Date
366-
? value
367-
: value != null
368-
? Number(value)
369-
: 0
368+
// Preserve strings, numbers, Dates, and bigints for comparison
369+
if (
370+
typeof value === `number` ||
371+
typeof value === `string` ||
372+
typeof value === `bigint` ||
373+
value instanceof Date
374+
) {
375+
return value
376+
}
377+
return value != null ? Number(value) : 0
370378
}
371379

372380
// Create a raw value extractor function for the expression to aggregate
@@ -383,9 +391,9 @@ function getAggregateFunction(aggExpr: Aggregate) {
383391
case `avg`:
384392
return avg(valueExtractor)
385393
case `min`:
386-
return min(valueExtractorWithDate)
394+
return min(valueExtractorForMinMax)
387395
case `max`:
388-
return max(valueExtractorWithDate)
396+
return max(valueExtractorForMinMax)
389397
default:
390398
throw new UnsupportedAggregateFunctionError(aggExpr.name)
391399
}
@@ -394,20 +402,15 @@ function getAggregateFunction(aggExpr: Aggregate) {
394402
/**
395403
* Transforms expressions to replace aggregate functions with references to computed values.
396404
*
397-
* This function is used in both ORDER BY and HAVING clauses to transform expressions that reference:
398-
* 1. Aggregate functions (e.g., `max()`, `count()`) - replaces with references to computed aggregates in SELECT
399-
* 2. SELECT field references via $selected namespace (e.g., `$selected.latestActivity`) - validates and passes through unchanged
400-
*
401-
* For aggregate expressions, it finds matching aggregates in the SELECT clause and replaces them with
402-
* PropRef([resultAlias, alias]) to reference the computed aggregate value.
405+
* For aggregate expressions, finds matching aggregates in the SELECT clause and replaces them
406+
* with PropRef([resultAlias, alias]) to reference the computed aggregate value.
403407
*
404-
* For ref expressions using the $selected namespace, it validates that the field exists in the SELECT clause
405-
* and passes them through unchanged (since $selected is already the correct namespace). All other ref expressions
406-
* are passed through unchanged (treating them as table column references).
408+
* Ref expressions (table columns and $selected fields) and value expressions are passed through unchanged.
409+
* Function expressions are recursively transformed.
407410
*
408411
* @param havingExpr - The expression to transform (can be aggregate, ref, func, or val)
409412
* @param selectClause - The SELECT clause containing aliases and aggregate definitions
410-
* @param resultAlias - The namespace alias for SELECT results (default: '$selected', used for aggregate references)
413+
* @param resultAlias - The namespace alias for SELECT results (default: '$selected')
411414
* @returns A transformed BasicExpression that references computed values instead of raw expressions
412415
*/
413416
export function replaceAggregatesByRefs(
@@ -439,41 +442,11 @@ export function replaceAggregatesByRefs(
439442
return new Func(funcExpr.name, transformedArgs)
440443
}
441444

442-
case `ref`: {
443-
const refExpr = havingExpr
444-
const path = refExpr.path
445-
446-
if (path.length === 0) {
447-
// Empty path - pass through
448-
return havingExpr as BasicExpression
449-
}
450-
451-
// Check if this is a $selected reference
452-
if (path.length > 0 && path[0] === `$selected`) {
453-
// Extract the field path after $selected
454-
const fieldPath = path.slice(1)
455-
456-
if (fieldPath.length === 0) {
457-
// Just $selected without a field - pass through unchanged
458-
return havingExpr as BasicExpression
459-
}
460-
461-
// Verify the field exists in SELECT clause
462-
const alias = fieldPath.join(`.`)
463-
if (alias in selectClause) {
464-
// Pass through unchanged - $selected is already the correct namespace
465-
return havingExpr as BasicExpression
466-
}
467-
468-
// Field doesn't exist in SELECT - this is an error, but we'll pass through for now
469-
// (Could throw an error here in the future)
470-
return havingExpr as BasicExpression
471-
}
472-
473-
// Not a $selected reference - this is a table column reference, pass through unchanged
474-
// SELECT fields should only be accessed via $selected namespace
445+
case `ref`:
446+
// Ref expressions are passed through unchanged - they reference either:
447+
// - $selected fields (which are already in the correct namespace)
448+
// - Table column references (which remain valid)
475449
return havingExpr as BasicExpression
476-
}
477450

478451
case `val`:
479452
// Return as-is

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,49 @@ function createGroupByTests(autoIndex: `off` | `eager`): void {
402402
expect(books?.order_count).toBe(3)
403403
expect(books?.total_amount).toBe(800) // 150+250+400
404404
})
405+
406+
test(`min/max on string fields`, () => {
407+
const categorySummary = createLiveQueryCollection({
408+
startSync: true,
409+
query: (q) =>
410+
q
411+
.from({ orders: ordersCollection })
412+
.groupBy(({ orders }) => orders.customer_id)
413+
.select(({ orders }) => ({
414+
customer_id: orders.customer_id,
415+
first_status: min(orders.status), // alphabetically first
416+
last_status: max(orders.status), // alphabetically last
417+
first_category: min(orders.product_category),
418+
last_category: max(orders.product_category),
419+
})),
420+
})
421+
422+
expect(categorySummary.size).toBe(3) // 3 customers
423+
424+
// Customer 1: orders 1, 2, 7 (statuses: completed, completed, completed; categories: electronics, electronics, books)
425+
const customer1 = categorySummary.get(1)
426+
expect(customer1?.customer_id).toBe(1)
427+
expect(customer1?.first_status).toBe(`completed`) // all completed
428+
expect(customer1?.last_status).toBe(`completed`)
429+
expect(customer1?.first_category).toBe(`books`) // alphabetically books < electronics
430+
expect(customer1?.last_category).toBe(`electronics`)
431+
432+
// Customer 2: orders 3, 4 (statuses: pending, completed; categories: books, electronics)
433+
const customer2 = categorySummary.get(2)
434+
expect(customer2?.customer_id).toBe(2)
435+
expect(customer2?.first_status).toBe(`completed`) // alphabetically completed < pending
436+
expect(customer2?.last_status).toBe(`pending`)
437+
expect(customer2?.first_category).toBe(`books`)
438+
expect(customer2?.last_category).toBe(`electronics`)
439+
440+
// Customer 3: orders 5, 6 (statuses: pending, cancelled; categories: books, electronics)
441+
const customer3 = categorySummary.get(3)
442+
expect(customer3?.customer_id).toBe(3)
443+
expect(customer3?.first_status).toBe(`cancelled`) // alphabetically cancelled < pending
444+
expect(customer3?.last_status).toBe(`pending`)
445+
expect(customer3?.first_category).toBe(`books`)
446+
expect(customer3?.last_category).toBe(`electronics`)
447+
})
405448
})
406449

407450
describe(`Multiple Column Grouping`, () => {

0 commit comments

Comments
 (0)