@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.
- package/dist/cjs/query/compiler/group-by.cjs +88 -8
- package/dist/cjs/query/compiler/group-by.cjs.map +1 -1
- package/dist/cjs/query/compiler/group-by.d.cts +6 -0
- package/dist/cjs/query/compiler/index.cjs +1 -1
- package/dist/cjs/query/compiler/index.cjs.map +1 -1
- package/dist/cjs/query/compiler/select.cjs +2 -1
- package/dist/cjs/query/compiler/select.cjs.map +1 -1
- package/dist/cjs/query/live/collection-config-builder.cjs +22 -1
- package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -1
- package/dist/esm/query/compiler/group-by.d.ts +6 -0
- package/dist/esm/query/compiler/group-by.js +89 -9
- package/dist/esm/query/compiler/group-by.js.map +1 -1
- package/dist/esm/query/compiler/index.js +2 -2
- package/dist/esm/query/compiler/index.js.map +1 -1
- package/dist/esm/query/compiler/select.js +2 -1
- package/dist/esm/query/compiler/select.js.map +1 -1
- package/dist/esm/query/live/collection-config-builder.js +22 -1
- package/dist/esm/query/live/collection-config-builder.js.map +1 -1
- package/package.json +1 -1
- package/src/query/compiler/group-by.ts +131 -14
- package/src/query/compiler/index.ts +2 -2
- package/src/query/compiler/select.ts +5 -2
- package/src/query/live/collection-config-builder.ts +30 -1
|
@@ -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
|
-
|
|
94
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
761
|
+
changesToApply.forEach(this.applyChanges.bind(this, config))
|
|
733
762
|
commit()
|
|
734
763
|
pendingChanges = new Map()
|
|
735
764
|
}
|