@tanstack/db 0.5.28 → 0.5.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@ import {
5
5
  map,
6
6
  serializeValue,
7
7
  } from '@tanstack/db-ivm'
8
- import { Func, PropRef, getHavingExpression } from '../ir.js'
8
+ import { Func, PropRef, getHavingExpression, isExpressionLike } from '../ir.js'
9
9
  import {
10
10
  AggregateFunctionNotInSelectError,
11
11
  NonAggregateExpressionNotInGroupByError,
@@ -49,8 +49,8 @@ function validateAndCreateMapping(
49
49
 
50
50
  // Validate each SELECT expression
51
51
  for (const [alias, expr] of Object.entries(selectClause)) {
52
- if (expr.type === `agg`) {
53
- // Aggregate expressions are allowed and don't need to be in GROUP BY
52
+ if (expr.type === `agg` || containsAggregate(expr)) {
53
+ // Aggregate expressions (plain or wrapped) are allowed and don't need to be in GROUP BY
54
54
  continue
55
55
  }
56
56
 
@@ -86,12 +86,26 @@ export function processGroupBy(
86
86
  // For single-group aggregation, create a single group with all data
87
87
  const aggregates: Record<string, any> = {}
88
88
 
89
+ // Expressions that wrap aggregates (e.g. coalesce(count(...), 0)).
90
+ // Keys are the original SELECT aliases; values are pre-compiled evaluators
91
+ // over the transformed (aggregate-free) expression.
92
+ const wrappedAggExprs: Record<string, (data: any) => any> = {}
93
+ const aggCounter = { value: 0 }
94
+
89
95
  if (selectClause) {
90
96
  // Scan the SELECT clause for aggregate functions
91
97
  for (const [alias, expr] of Object.entries(selectClause)) {
92
98
  if (expr.type === `agg`) {
93
- const aggExpr = expr
94
- aggregates[alias] = getAggregateFunction(aggExpr)
99
+ aggregates[alias] = getAggregateFunction(expr)
100
+ } else if (containsAggregate(expr)) {
101
+ const { transformed, extracted } = extractAndReplaceAggregates(
102
+ expr as BasicExpression | Aggregate,
103
+ aggCounter,
104
+ )
105
+ for (const [syntheticAlias, aggExpr] of Object.entries(extracted)) {
106
+ aggregates[syntheticAlias] = getAggregateFunction(aggExpr)
107
+ }
108
+ wrappedAggExprs[alias] = compileExpression(transformed)
95
109
  }
96
110
  }
97
111
  }
@@ -112,13 +126,17 @@ export function processGroupBy(
112
126
  const finalResults: Record<string, any> = { ...selectResults }
113
127
 
114
128
  if (selectClause) {
115
- // Update with aggregate results
129
+ // First pass: populate plain aggregate results and synthetic aliases
116
130
  for (const [alias, expr] of Object.entries(selectClause)) {
117
131
  if (expr.type === `agg`) {
118
132
  finalResults[alias] = aggregatedRow[alias]
119
133
  }
120
- // Non-aggregates keep their original values from early SELECT processing
121
134
  }
135
+ evaluateWrappedAggregates(
136
+ finalResults,
137
+ aggregatedRow as Record<string, any>,
138
+ wrappedAggExprs,
139
+ )
122
140
  }
123
141
 
124
142
  // Use a single key for the result and update $selected
@@ -201,13 +219,23 @@ export function processGroupBy(
201
219
 
202
220
  // Create aggregate functions for any aggregated columns in the SELECT clause
203
221
  const aggregates: Record<string, any> = {}
222
+ const wrappedAggExprs: Record<string, (data: any) => any> = {}
223
+ const aggCounter = { value: 0 }
204
224
 
205
225
  if (selectClause) {
206
226
  // Scan the SELECT clause for aggregate functions
207
227
  for (const [alias, expr] of Object.entries(selectClause)) {
208
228
  if (expr.type === `agg`) {
209
- const aggExpr = expr
210
- aggregates[alias] = getAggregateFunction(aggExpr)
229
+ aggregates[alias] = getAggregateFunction(expr)
230
+ } else if (containsAggregate(expr)) {
231
+ const { transformed, extracted } = extractAndReplaceAggregates(
232
+ expr as BasicExpression | Aggregate,
233
+ aggCounter,
234
+ )
235
+ for (const [syntheticAlias, aggExpr] of Object.entries(extracted)) {
236
+ aggregates[syntheticAlias] = getAggregateFunction(aggExpr)
237
+ }
238
+ wrappedAggExprs[alias] = compileExpression(transformed)
211
239
  }
212
240
  }
213
241
  }
@@ -223,9 +251,11 @@ export function processGroupBy(
223
251
  const finalResults: Record<string, any> = {}
224
252
 
225
253
  if (selectClause) {
226
- // Process each SELECT expression
254
+ // First pass: populate group keys, plain aggregates, and synthetic aliases
227
255
  for (const [alias, expr] of Object.entries(selectClause)) {
228
- if (expr.type !== `agg`) {
256
+ if (expr.type === `agg`) {
257
+ finalResults[alias] = aggregatedRow[alias]
258
+ } else if (!wrappedAggExprs[alias]) {
229
259
  // Use cached mapping to get the corresponding __key_X for non-aggregates
230
260
  const groupIndex = mapping.selectToGroupByIndex.get(alias)
231
261
  if (groupIndex !== undefined) {
@@ -234,11 +264,13 @@ export function processGroupBy(
234
264
  // Fallback to original SELECT results
235
265
  finalResults[alias] = selectResults[alias]
236
266
  }
237
- } else {
238
- // Use aggregate results
239
- finalResults[alias] = aggregatedRow[alias]
240
267
  }
241
268
  }
269
+ evaluateWrappedAggregates(
270
+ finalResults,
271
+ aggregatedRow as Record<string, any>,
272
+ wrappedAggExprs,
273
+ )
242
274
  } else {
243
275
  // No SELECT clause - just use the group keys
244
276
  for (let i = 0; i < groupByClause.length; i++) {
@@ -457,6 +489,91 @@ export function replaceAggregatesByRefs(
457
489
  }
458
490
  }
459
491
 
492
+ /**
493
+ * Evaluates wrapped-aggregate expressions against the aggregated row.
494
+ * Copies synthetic __agg_N values into finalResults so the compiled wrapper
495
+ * expressions can reference them, evaluates each wrapper, then removes the
496
+ * synthetic keys so they don't leak onto user-visible result rows.
497
+ */
498
+ function evaluateWrappedAggregates(
499
+ finalResults: Record<string, any>,
500
+ aggregatedRow: Record<string, any>,
501
+ wrappedAggExprs: Record<string, (data: any) => any>,
502
+ ): void {
503
+ for (const key of Object.keys(aggregatedRow)) {
504
+ if (key.startsWith(`__agg_`)) {
505
+ finalResults[key] = aggregatedRow[key]
506
+ }
507
+ }
508
+ for (const [alias, evaluator] of Object.entries(wrappedAggExprs)) {
509
+ finalResults[alias] = evaluator({ $selected: finalResults })
510
+ }
511
+ for (const key of Object.keys(finalResults)) {
512
+ if (key.startsWith(`__agg_`)) delete finalResults[key]
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Checks whether an expression contains an aggregate anywhere in its tree.
518
+ * Returns true for a top-level Aggregate, or a Func whose args (recursively)
519
+ * contain an Aggregate. Safely returns false for nested Select objects.
520
+ */
521
+ export function containsAggregate(
522
+ expr: BasicExpression | Aggregate | Select,
523
+ ): boolean {
524
+ if (!isExpressionLike(expr)) {
525
+ return false
526
+ }
527
+ if (expr.type === `agg`) {
528
+ return true
529
+ }
530
+ if (expr.type === `func`) {
531
+ return expr.args.some((arg: BasicExpression | Aggregate) =>
532
+ containsAggregate(arg),
533
+ )
534
+ }
535
+ return false
536
+ }
537
+
538
+ /**
539
+ * Walks an expression tree containing nested aggregates.
540
+ * Each Aggregate node is extracted, assigned a synthetic alias (__agg_N),
541
+ * and replaced with PropRef(["$selected", "__agg_N"]) so the wrapper
542
+ * expression can be compiled as a pure BasicExpression after groupBy
543
+ * populates the synthetic values.
544
+ */
545
+ function extractAndReplaceAggregates(
546
+ expr: BasicExpression | Aggregate,
547
+ counter: { value: number },
548
+ ): {
549
+ transformed: BasicExpression
550
+ extracted: Record<string, Aggregate>
551
+ } {
552
+ if (expr.type === `agg`) {
553
+ const alias = `__agg_${counter.value++}`
554
+ return {
555
+ transformed: new PropRef([`$selected`, alias]),
556
+ extracted: { [alias]: expr },
557
+ }
558
+ }
559
+
560
+ if (expr.type === `func`) {
561
+ const allExtracted: Record<string, Aggregate> = {}
562
+ const newArgs = expr.args.map((arg: BasicExpression | Aggregate) => {
563
+ const result = extractAndReplaceAggregates(arg, counter)
564
+ Object.assign(allExtracted, result.extracted)
565
+ return result.transformed
566
+ })
567
+ return {
568
+ transformed: new Func(expr.name, newArgs),
569
+ extracted: allExtracted,
570
+ }
571
+ }
572
+
573
+ // ref / val – pass through unchanged
574
+ return { transformed: expr as BasicExpression, extracted: {} }
575
+ }
576
+
460
577
  /**
461
578
  * Checks if two aggregate expressions are equal
462
579
  */
@@ -11,7 +11,7 @@ import {
11
11
  import { PropRef, Value as ValClass, getWhereExpression } from '../ir.js'
12
12
  import { compileExpression, toBooleanPredicate } from './evaluators.js'
13
13
  import { processJoins } from './joins.js'
14
- import { processGroupBy } from './group-by.js'
14
+ import { containsAggregate, processGroupBy } from './group-by.js'
15
15
  import { processOrderBy } from './order-by.js'
16
16
  import { processSelect } from './select.js'
17
17
  import type { CollectionSubscription } from '../../collection/subscription.js'
@@ -268,7 +268,7 @@ export function compileQuery(
268
268
  } else if (query.select) {
269
269
  // Check if SELECT contains aggregates but no GROUP BY (implicit single-group aggregation)
270
270
  const hasAggregates = Object.values(query.select).some(
271
- (expr) => expr.type === `agg`,
271
+ (expr) => expr.type === `agg` || containsAggregate(expr),
272
272
  )
273
273
  if (hasAggregates) {
274
274
  // Handle implicit single-group aggregation
@@ -2,6 +2,7 @@ import { map } from '@tanstack/db-ivm'
2
2
  import { PropRef, Value as ValClass, isExpressionLike } from '../ir.js'
3
3
  import { AggregateNotSupportedError } from '../../errors.js'
4
4
  import { compileExpression } from './evaluators.js'
5
+ import { containsAggregate } from './group-by.js'
5
6
  import type { Aggregate, BasicExpression, Select } from '../ir.js'
6
7
  import type {
7
8
  KeyedStream,
@@ -226,8 +227,10 @@ function addFromObject(
226
227
  continue
227
228
  }
228
229
 
229
- if (isAggregateExpression(expression)) {
230
- // Placeholder for group-by processing later
230
+ if (isAggregateExpression(expression) || containsAggregate(expression)) {
231
+ // Placeholder for group-by processing later.
232
+ // Both plain aggregates (count(...)) and expressions wrapping
233
+ // aggregates (coalesce(count(...), 0)) are deferred to processGroupBy.
231
234
  ops.push({
232
235
  kind: `field`,
233
236
  alias: [...prefixPath, key].join(`.`),
@@ -728,8 +728,37 @@ export class CollectionConfigBuilder<
728
728
  if (pendingChanges.size === 0) {
729
729
  return
730
730
  }
731
+
732
+ let changesToApply = pendingChanges
733
+
734
+ // When a custom getKey is provided, multiple D2 internal keys may map
735
+ // to the same user-visible key. Re-accumulate by custom key so that a
736
+ // retract + insert for the same logical row merges into an UPDATE
737
+ // instead of a separate DELETE and INSERT that can race.
738
+ if (this.config.getKey) {
739
+ const merged = new Map<unknown, Changes<TResult>>()
740
+ for (const [, changes] of pendingChanges) {
741
+ const customKey = this.config.getKey(changes.value)
742
+ const existing = merged.get(customKey)
743
+ if (existing) {
744
+ existing.inserts += changes.inserts
745
+ existing.deletes += changes.deletes
746
+ // Keep the value from the insert side (the new value)
747
+ if (changes.inserts > 0) {
748
+ existing.value = changes.value
749
+ if (changes.orderByIndex !== undefined) {
750
+ existing.orderByIndex = changes.orderByIndex
751
+ }
752
+ }
753
+ } else {
754
+ merged.set(customKey, { ...changes })
755
+ }
756
+ }
757
+ changesToApply = merged
758
+ }
759
+
731
760
  begin()
732
- pendingChanges.forEach(this.applyChanges.bind(this, config))
761
+ changesToApply.forEach(this.applyChanges.bind(this, config))
733
762
  commit()
734
763
  pendingChanges = new Map()
735
764
  }