squirreling 0.12.26 → 0.12.27

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.12.26",
3
+ "version": "0.12.27",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -0,0 +1,89 @@
1
+ import { keyify } from './utils.js'
2
+
3
+ /**
4
+ * @import { SqlPrimitive } from '../types.js'
5
+ */
6
+
7
+ /**
8
+ * Incremental state for one streamable aggregate. Shared by the streaming
9
+ * aggregate executor and the scanColumn fast path so COUNT/COUNTIF/SUM/AVG/
10
+ * MIN/MAX fold semantics live in one place.
11
+ *
12
+ * @typedef {{
13
+ * count: number,
14
+ * sum: number,
15
+ * min: SqlPrimitive,
16
+ * max: SqlPrimitive,
17
+ * seen: Set<unknown> | null,
18
+ * }} Accumulator
19
+ */
20
+
21
+ /**
22
+ * @param {string} funcName
23
+ * @param {boolean} [distinct]
24
+ * @returns {Accumulator}
25
+ */
26
+ export function newAccumulator(funcName, distinct) {
27
+ return {
28
+ count: 0,
29
+ sum: 0,
30
+ min: null,
31
+ max: null,
32
+ seen: funcName === 'COUNT' && distinct ? new Set() : null,
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Folds one value into an accumulator, matching the buffered semantics in
38
+ * evaluate.js: COUNT counts non-null, COUNTIF counts truthy, MIN/MAX compare
39
+ * raw values, SUM/AVG only accumulate finite numbers.
40
+ *
41
+ * @param {string} funcName
42
+ * @param {Accumulator} acc
43
+ * @param {SqlPrimitive} value
44
+ */
45
+ export function updateAccumulator(funcName, acc, value) {
46
+ switch (funcName) {
47
+ case 'COUNT':
48
+ if (value == null) break
49
+ if (acc.seen) acc.seen.add(keyify(value))
50
+ else acc.count++
51
+ break
52
+ case 'COUNTIF':
53
+ if (value) acc.count++
54
+ break
55
+ case 'MIN':
56
+ if (value != null && (acc.min === null || value < acc.min)) acc.min = value
57
+ break
58
+ case 'MAX':
59
+ if (value != null && (acc.max === null || value > acc.max)) acc.max = value
60
+ break
61
+ default: { // SUM, AVG
62
+ if (value == null) break
63
+ const num = Number(value)
64
+ if (Number.isFinite(num)) {
65
+ acc.sum += num
66
+ acc.count++
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Reduces an accumulator to its final aggregate value.
74
+ *
75
+ * @param {string} funcName
76
+ * @param {Accumulator} acc
77
+ * @returns {SqlPrimitive}
78
+ */
79
+ export function finalizeAccumulator(funcName, acc) {
80
+ switch (funcName) {
81
+ case 'COUNT': return acc.seen ? acc.seen.size : acc.count
82
+ case 'COUNTIF': return acc.count
83
+ case 'SUM': return acc.count === 0 ? null : acc.sum
84
+ case 'AVG': return acc.count === 0 ? null : acc.sum / acc.count
85
+ case 'MIN': return acc.min
86
+ case 'MAX': return acc.max
87
+ default: return null
88
+ }
89
+ }
@@ -1,7 +1,9 @@
1
1
  import { derivedAlias } from '../expression/alias.js'
2
2
  import { evaluateExpr } from '../expression/evaluate.js'
3
+ import { finalizeAccumulator, newAccumulator, updateAccumulator } from './accumulator.js'
3
4
  import { executePlan, selectColumnNames } from './execute.js'
4
5
  import { sortEntriesByTerms } from './sort.js'
6
+ import { planStreamingAggregates, streamingHashAggregateRows, streamingScalarAggregateRows } from './streamingAggregate.js'
5
7
  import { keyify } from './utils.js'
6
8
  import { yieldToEventLoop } from './yield.js'
7
9
 
@@ -80,6 +82,14 @@ function aggregateContextRow(group, aggregateRow) {
80
82
  */
81
83
  export function executeHashAggregate(plan, context) {
82
84
  const child = executePlan({ plan: plan.child, context })
85
+ const streaming = planStreamingAggregates(plan, child.columns)
86
+ if (streaming) {
87
+ return {
88
+ columns: selectColumnNames(plan.columns, child.columns),
89
+ maxRows: child.maxRows,
90
+ rows: streamingHashAggregateRows({ plan, streaming, child, context }),
91
+ }
92
+ }
83
93
  return {
84
94
  columns: selectColumnNames(plan.columns, child.columns),
85
95
  maxRows: child.maxRows,
@@ -196,6 +206,15 @@ export function executeScalarAggregate(plan, context) {
196
206
  }
197
207
 
198
208
  const child = executePlan({ plan: plan.child, context })
209
+ const streaming = planStreamingAggregates(plan, child.columns)
210
+ if (streaming) {
211
+ return {
212
+ columns: selectColumnNames(plan.columns, child.columns),
213
+ numRows: plan.having ? undefined : 1,
214
+ maxRows: 1,
215
+ rows: streamingScalarAggregateRows({ plan, streaming, child, context }),
216
+ }
217
+ }
199
218
  return {
200
219
  columns: selectColumnNames(plan.columns, child.columns),
201
220
  numRows: plan.having ? undefined : 1,
@@ -344,17 +363,6 @@ function extractColumnAggSpec({ expr, alias }) {
344
363
  }
345
364
  }
346
365
 
347
- /**
348
- * @typedef {{
349
- * spec: ColumnAggSpec,
350
- * count: number,
351
- * sum: number,
352
- * min: SqlPrimitive,
353
- * max: SqlPrimitive,
354
- * seen: Set<unknown> | null,
355
- * }} AggAccumulator
356
- */
357
-
358
366
  /**
359
367
  * Scans a column once and computes every aggregate over it in a single pass.
360
368
  * All specs share the one scanColumn walk, so MIN(x)/MAX(x)/AVG(x) decode x once.
@@ -371,41 +379,15 @@ async function scanColumnGroup({ table, specs, limit, offset, signal }) {
371
379
  const { column } = specs[0]
372
380
  const values = table.scanColumn({ column, limit, offset, signal })
373
381
 
374
- /** @type {AggAccumulator[]} */
375
- const accs = specs.map(spec => /** @type {AggAccumulator} */ ({
376
- spec,
377
- count: 0,
378
- sum: 0,
379
- min: null,
380
- max: null,
381
- seen: spec.funcName === 'COUNT' && spec.distinct ? new Set() : null,
382
- }))
382
+ const accs = specs.map(spec => ({ spec, acc: newAccumulator(spec.funcName, spec.distinct) }))
383
383
 
384
384
  for await (const chunk of values) {
385
385
  signal?.throwIfAborted()
386
386
  for (let i = 0; i < chunk.length; i++) {
387
387
  const v = chunk[i]
388
388
  if (v == null) continue
389
- for (const acc of accs) {
390
- switch (acc.spec.funcName) {
391
- case 'COUNT':
392
- if (acc.seen) acc.seen.add(keyify(v))
393
- else acc.count++
394
- break
395
- case 'MIN':
396
- if (acc.min === null || v < acc.min) acc.min = v
397
- break
398
- case 'MAX':
399
- if (acc.max === null || v > acc.max) acc.max = v
400
- break
401
- default: { // SUM, AVG
402
- const num = Number(v)
403
- if (Number.isFinite(num)) {
404
- acc.sum += num
405
- acc.count++
406
- }
407
- }
408
- }
389
+ for (const { spec, acc } of accs) {
390
+ updateAccumulator(spec.funcName, acc, v)
409
391
  }
410
392
  }
411
393
  }
@@ -413,25 +395,8 @@ async function scanColumnGroup({ table, specs, limit, offset, signal }) {
413
395
 
414
396
  /** @type {Map<string, SqlPrimitive>} */
415
397
  const result = new Map()
416
- for (const acc of accs) {
417
- result.set(acc.spec.alias, finalizeAgg(acc))
398
+ for (const { spec, acc } of accs) {
399
+ result.set(spec.alias, finalizeAccumulator(spec.funcName, acc))
418
400
  }
419
401
  return result
420
402
  }
421
-
422
- /**
423
- * Reduces one accumulator to its final aggregate value.
424
- *
425
- * @param {AggAccumulator} acc
426
- * @returns {SqlPrimitive}
427
- */
428
- function finalizeAgg(acc) {
429
- switch (acc.spec.funcName) {
430
- case 'COUNT': return acc.seen ? acc.seen.size : acc.count
431
- case 'SUM': return acc.count === 0 ? null : acc.sum
432
- case 'AVG': return acc.count === 0 ? null : acc.sum / acc.count
433
- case 'MIN': return acc.min
434
- case 'MAX': return acc.max
435
- default: return null
436
- }
437
- }
@@ -4,16 +4,21 @@ import { executePlan } from './execute.js'
4
4
  import { compareForTerm } from './utils.js'
5
5
 
6
6
  /**
7
- * @import { AsyncRow, ExecuteContext, OrderByItem, QueryResults, SqlPrimitive } from '../types.js'
7
+ * @import { AsyncRow, ExecuteContext, ExprNode, OrderByItem, QueryResults, SqlPrimitive } from '../types.js'
8
8
  * @import { SortNode } from '../plan/types.js'
9
9
  */
10
10
 
11
11
  const MAX_CHUNK = 256
12
12
 
13
13
  /**
14
+ * When exprs is set, exprs[i] is evaluated for ORDER BY term i instead of the
15
+ * term's own expression, so callers can pre-substitute per-entry values (for
16
+ * example, finalized aggregates) while keeping tie-aware term evaluation.
17
+ *
14
18
  * @typedef {{
15
19
  * row: AsyncRow,
16
20
  * rows?: AsyncRow[],
21
+ * exprs?: ExprNode[],
17
22
  * }} SortEntry
18
23
  */
19
24
 
@@ -63,7 +68,7 @@ export async function sortEntriesByTerms({ entries, orderBy, context, cacheValue
63
68
  const chunk = missing.slice(start, start + chunkSize)
64
69
  const values = await Promise.all(chunk.map(idx =>
65
70
  evaluateExpr({
66
- node: term.expr,
71
+ node: entries[idx].exprs?.[orderByIdx] ?? term.expr,
67
72
  row: entries[idx].row,
68
73
  rows: entries[idx].rows,
69
74
  context,
@@ -0,0 +1,603 @@
1
+ import { derivedAlias } from '../expression/alias.js'
2
+ import { evaluateAll, evaluateExpr } from '../expression/evaluate.js'
3
+ import { collectColumnsFromExpr } from '../plan/columns.js'
4
+ import { isAggregateFunc } from '../validation/functions.js'
5
+ import { finalizeAccumulator, newAccumulator, updateAccumulator } from './accumulator.js'
6
+ import { sortEntriesByTerms } from './sort.js'
7
+ import { keyify } from './utils.js'
8
+ import { yieldToEventLoop } from './yield.js'
9
+
10
+ /**
11
+ * @import { AsyncCells, AsyncRow, ExecuteContext, ExprNode, FunctionNode, IdentifierNode, QueryResults, SelectColumn, SqlPrimitive } from '../types.js'
12
+ * @import { HashAggregateNode, ScalarAggregateNode } from '../plan/types.js'
13
+ * @import { Accumulator } from './accumulator.js'
14
+ */
15
+
16
+ // Accumulate rows in chunks of this size so aborts can fire and async cells overlap
17
+ const CHUNK_SIZE = 4000
18
+
19
+ // Aggregate functions whose state can be accumulated one row at a time with
20
+ // bounded memory. Aggregates outside this set (MEDIAN, ARRAY_AGG, STDDEV, ...)
21
+ // need the full value set, so their queries buffer rows instead.
22
+ const STREAMABLE_FUNCS = new Set(['COUNT', 'COUNTIF', 'SUM', 'AVG', 'MIN', 'MAX'])
23
+
24
+ /**
25
+ * Specs are keyed by aggregate node identity, so two aggregates that differ
26
+ * only in FILTER accumulate separately even though their derived aliases match.
27
+ *
28
+ * @typedef {{
29
+ * node: FunctionNode,
30
+ * funcName: string,
31
+ * star: boolean,
32
+ * }} StreamingAggSpec
33
+ */
34
+
35
+ /**
36
+ * @typedef {{
37
+ * firstRow: AsyncRow | undefined,
38
+ * keyValues: SqlPrimitive[],
39
+ * accumulators: Accumulator[],
40
+ * }} StreamingGroup
41
+ */
42
+
43
+ /**
44
+ * The streaming plan for an aggregate node: which aggregate calls to
45
+ * accumulate, which expression nodes are group key references (substituted
46
+ * from the group's key values), and whether any expression still needs a
47
+ * representative row from the group. When needsRow is false, no input rows
48
+ * are retained, so memory is bounded by the number of groups — plus, for
49
+ * COUNT(DISTINCT ...), each group's set of distinct values — even for
50
+ * high-cardinality GROUP BY.
51
+ *
52
+ * @typedef {{
53
+ * specs: StreamingAggSpec[],
54
+ * keyRefs: Map<ExprNode, number>,
55
+ * needsRow: boolean,
56
+ * }} StreamingAggPlan
57
+ */
58
+
59
+ /**
60
+ * Structural signature of an expression node, ignoring source positions, so
61
+ * an expression repeated in SELECT and GROUP BY compares equal.
62
+ *
63
+ * @param {ExprNode} node
64
+ * @returns {string}
65
+ */
66
+ function exprSig(node) {
67
+ return JSON.stringify(node, (key, value) => {
68
+ if (key === 'positionStart' || key === 'positionEnd') return undefined
69
+ // JSON.stringify throws on BigInt literal values; wrap so 1n !== '1n'
70
+ if (typeof value === 'bigint') return { bigint: value.toString() }
71
+ return value
72
+ })
73
+ }
74
+
75
+ /**
76
+ * Extracts the aggregate calls an aggregate node needs so they can be
77
+ * computed incrementally, without buffering the group's rows. Returns
78
+ * undefined when any expression needs a buffered group: an aggregate outside
79
+ * STREAMABLE_FUNCS, an aggregate over a non-scalar argument, or a subquery.
80
+ * Also returns undefined when an aggregate references a column the child
81
+ * does not produce: projection pushdown prunes columns whose output cells
82
+ * are never read, and only the buffered path defers evaluation of those cells.
83
+ *
84
+ * @param {Pick<HashAggregateNode, 'columns' | 'having'> & Partial<Pick<HashAggregateNode, 'orderBy' | 'groupBy'>>} plan
85
+ * @param {string[]} [childColumns] - columns produced by the child plan
86
+ * @returns {StreamingAggPlan | undefined}
87
+ */
88
+ export function planStreamingAggregates({ columns, having, orderBy, groupBy }, childColumns) {
89
+ const groupExprs = groupBy ?? []
90
+ const groupSigs = groupExprs.map(exprSig)
91
+ /** @type {StreamingAggSpec[]} */
92
+ const specs = []
93
+ /** @type {Map<ExprNode, number>} */
94
+ const keyRefs = new Map()
95
+ let needsRow = false
96
+
97
+ /**
98
+ * Index of the group key the expression structurally matches, or -1.
99
+ * Only exact matches qualify: a bare identifier and a qualified group key
100
+ * (or vice versa) can resolve to different columns in a join, so mixed
101
+ * qualification falls back to evaluating against the representative row.
102
+ *
103
+ * @param {ExprNode} node
104
+ * @returns {number}
105
+ */
106
+ function matchGroupKey(node) {
107
+ const signature = exprSig(node)
108
+ for (let i = 0; i < groupExprs.length; i++) {
109
+ if (groupSigs[i] === signature) return i
110
+ }
111
+ return -1
112
+ }
113
+
114
+ /**
115
+ * Walks an expression collecting streamable aggregate calls and group key
116
+ * references. Returns false if the expression cannot be evaluated from
117
+ * precomputed values plus a representative row. `lazy` marks positions the
118
+ * evaluator can skip (short-circuited AND/OR right sides, CASE branches,
119
+ * and select or sort expressions of a group HAVING may reject): an
120
+ * aggregate there may never be evaluated by the buffered path, so
121
+ * accumulating it eagerly could evaluate expressions the query never asks
122
+ * for, and the query falls back to buffered aggregation. A FILTER-less
123
+ * star aggregate is exempt: accumulating it evaluates nothing against
124
+ * input rows, so eager accumulation is unobservable.
125
+ *
126
+ * @param {ExprNode} node
127
+ * @param {boolean} lazy
128
+ * @returns {boolean}
129
+ */
130
+ function walk(node, lazy) {
131
+ const keyIndex = matchGroupKey(node)
132
+ if (keyIndex >= 0) {
133
+ keyRefs.set(node, keyIndex)
134
+ return true
135
+ }
136
+ switch (node.type) {
137
+ case 'literal':
138
+ case 'interval':
139
+ return true
140
+ case 'identifier':
141
+ case 'star':
142
+ // resolves against the group's representative row
143
+ needsRow = true
144
+ return true
145
+ case 'unary':
146
+ return walk(node.argument, lazy)
147
+ case 'binary':
148
+ if (node.op === 'AND' || node.op === 'OR') {
149
+ // the right side is skipped when the left side short-circuits
150
+ return walk(node.left, lazy) && walk(node.right, true)
151
+ }
152
+ return walk(node.left, lazy) && walk(node.right, lazy)
153
+ case 'cast':
154
+ return walk(node.expr, lazy)
155
+ case 'case':
156
+ // only the first WHEN condition is always evaluated; later
157
+ // conditions, results, and ELSE run only when reached
158
+ return (!node.caseExpr || walk(node.caseExpr, lazy)) &&
159
+ node.whenClauses.every((w, i) => walk(w.condition, lazy || i > 0) && walk(w.result, true)) &&
160
+ (!node.elseResult || walk(node.elseResult, true))
161
+ case 'in valuelist':
162
+ // values after the first are skipped once an earlier value matches
163
+ return walk(node.expr, lazy) && node.values.every((v, i) => walk(v, lazy || i > 0))
164
+ case 'function': {
165
+ const funcName = node.funcName.toUpperCase()
166
+ if (!isAggregateFunc(funcName)) {
167
+ return node.args.every(arg => walk(arg, lazy))
168
+ }
169
+ if (!STREAMABLE_FUNCS.has(funcName)) return false
170
+ const star = node.args[0]?.type === 'star'
171
+ if (lazy && !(star && !node.filter)) return false
172
+ if (!star && !node.args.every(arg => isScalarExpr(arg))) return false
173
+ if (node.filter && !isScalarExpr(node.filter)) return false
174
+ if (!specs.some(spec => spec.node === node)) {
175
+ specs.push({ node, funcName, star })
176
+ }
177
+ return true
178
+ }
179
+ default:
180
+ // subqueries, EXISTS, IN (subquery), window functions
181
+ return false
182
+ }
183
+ }
184
+
185
+ // HAVING can reject a group before its output cells are ever read, so
186
+ // with HAVING present the buffered path may never evaluate SELECT or
187
+ // ORDER BY aggregates; treat those positions as lazy
188
+ const rejectable = Boolean(having)
189
+ for (const col of columns) {
190
+ if (col.type === 'star') {
191
+ needsRow = true
192
+ continue
193
+ }
194
+ if (!walk(col.expr, rejectable)) return
195
+ }
196
+ if (having && !walk(having, false)) return
197
+ const orderTerms = orderBy ?? []
198
+ for (let i = 0; i < orderTerms.length; i++) {
199
+ // the sorter evaluates later terms only to break ties on earlier terms
200
+ if (!walk(orderTerms[i].expr, rejectable || i > 0)) return
201
+ }
202
+ if (childColumns && !specsResolvable(specs, childColumns)) return
203
+ return { specs, keyRefs, needsRow }
204
+ }
205
+
206
+ /**
207
+ * Reports whether every identifier the streaming path evaluates eagerly
208
+ * (aggregate arguments and FILTER conditions) can resolve against the
209
+ * child's output columns, using the same exact-then-suffix matching as
210
+ * identifier evaluation. Anything unresolvable means projection pushdown
211
+ * pruned the column, so the query must buffer instead.
212
+ *
213
+ * @param {StreamingAggSpec[]} specs
214
+ * @param {string[]} childColumns
215
+ * @returns {boolean}
216
+ */
217
+ function specsResolvable(specs, childColumns) {
218
+ /** @type {IdentifierNode[]} */
219
+ const identifiers = []
220
+ for (const spec of specs) {
221
+ collectColumnsFromExpr(spec.node, identifiers)
222
+ }
223
+ return identifiers.every(({ prefix, name }) => {
224
+ if (childColumns.includes(prefix ? `${prefix}.${name}` : name)) return true
225
+ if (!prefix) return childColumns.some(col => col.endsWith('.' + name))
226
+ // a qualified name may also resolve as struct access on a base column,
227
+ // or fall back to the bare column part
228
+ return childColumns.includes(prefix) ||
229
+ childColumns.some(col => col.endsWith('.' + prefix)) ||
230
+ childColumns.includes(name)
231
+ })
232
+ }
233
+
234
+ /**
235
+ * Reports whether an expression is a plain scalar over row values: no
236
+ * aggregates and no subqueries, so it can be evaluated per input row.
237
+ *
238
+ * @param {ExprNode} node
239
+ * @returns {boolean}
240
+ */
241
+ function isScalarExpr(node) {
242
+ switch (node.type) {
243
+ case 'literal':
244
+ case 'identifier':
245
+ case 'star':
246
+ case 'interval':
247
+ return true
248
+ case 'unary':
249
+ return isScalarExpr(node.argument)
250
+ case 'binary':
251
+ return isScalarExpr(node.left) && isScalarExpr(node.right)
252
+ case 'cast':
253
+ return isScalarExpr(node.expr)
254
+ case 'case':
255
+ return (!node.caseExpr || isScalarExpr(node.caseExpr)) &&
256
+ node.whenClauses.every(w => isScalarExpr(w.condition) && isScalarExpr(w.result)) &&
257
+ (!node.elseResult || isScalarExpr(node.elseResult))
258
+ case 'in valuelist':
259
+ return isScalarExpr(node.expr) && node.values.every(v => isScalarExpr(v))
260
+ case 'function':
261
+ return !isAggregateFunc(node.funcName.toUpperCase()) && node.args.every(arg => isScalarExpr(arg))
262
+ default:
263
+ return false
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Replaces each precomputed node in an expression with its value as a
269
+ * literal: aggregate calls (keyed by node identity) and group key references,
270
+ * so the rest of the expression can be evaluated against a representative
271
+ * row. Nodes without precomputed values are returned unchanged.
272
+ *
273
+ * @param {ExprNode} node
274
+ * @param {Map<ExprNode, SqlPrimitive>} values - computed value per substituted node
275
+ * @returns {ExprNode}
276
+ */
277
+ function substituteValues(node, values) {
278
+ if (values.has(node)) {
279
+ // group keys over missing columns evaluate to undefined; preserve it
280
+ // so streaming output matches the buffered path's evaluation result
281
+ // eslint-disable-next-line no-extra-parens
282
+ const value = /** @type {SqlPrimitive} */ (values.get(node))
283
+ return {
284
+ type: 'literal',
285
+ value,
286
+ positionStart: node.positionStart,
287
+ positionEnd: node.positionEnd,
288
+ }
289
+ }
290
+ switch (node.type) {
291
+ case 'unary': {
292
+ const argument = substituteValues(node.argument, values)
293
+ return argument === node.argument ? node : { ...node, argument }
294
+ }
295
+ case 'binary': {
296
+ const left = substituteValues(node.left, values)
297
+ const right = substituteValues(node.right, values)
298
+ return left === node.left && right === node.right ? node : { ...node, left, right }
299
+ }
300
+ case 'cast': {
301
+ const expr = substituteValues(node.expr, values)
302
+ return expr === node.expr ? node : { ...node, expr }
303
+ }
304
+ case 'case': {
305
+ const caseExpr = node.caseExpr && substituteValues(node.caseExpr, values)
306
+ const whenClauses = node.whenClauses.map(w => {
307
+ const condition = substituteValues(w.condition, values)
308
+ const result = substituteValues(w.result, values)
309
+ return condition === w.condition && result === w.result ? w : { ...w, condition, result }
310
+ })
311
+ const elseResult = node.elseResult && substituteValues(node.elseResult, values)
312
+ return { ...node, caseExpr, whenClauses, elseResult }
313
+ }
314
+ case 'in valuelist': {
315
+ const expr = substituteValues(node.expr, values)
316
+ const valueNodes = node.values.map(v => substituteValues(v, values))
317
+ return { ...node, expr, values: valueNodes }
318
+ }
319
+ case 'function': {
320
+ const args = node.args.map(arg => substituteValues(arg, values))
321
+ return args.every((arg, i) => arg === node.args[i]) ? node : { ...node, args }
322
+ }
323
+ default:
324
+ return node
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Folds one chunk of rows into the group accumulators. Group keys, FILTER
330
+ * conditions, and aggregate arguments are each evaluated across the whole
331
+ * chunk so async cells overlap; the chunk is released afterwards.
332
+ *
333
+ * @param {object} options
334
+ * @param {AsyncRow[]} options.chunk
335
+ * @param {ExprNode[]} options.groupBy
336
+ * @param {StreamingAggSpec[]} options.specs
337
+ * @param {Map<unknown, StreamingGroup>} options.groups
338
+ * @param {boolean} options.needsRow - retain each group's first row?
339
+ * @param {ExecuteContext} options.context
340
+ * @returns {Promise<void>}
341
+ */
342
+ async function accumulateChunk({ chunk, groupBy, specs, groups, needsRow, context }) {
343
+ /** @type {SqlPrimitive[][] | undefined} */
344
+ let keyColumns
345
+ if (groupBy.length) {
346
+ keyColumns = await Promise.all(groupBy.map(expr => evaluateAll(expr, chunk, context)))
347
+ }
348
+
349
+ /** @type {(SqlPrimitive[] | undefined)[]} */
350
+ const filters = new Array(specs.length)
351
+ /** @type {(SqlPrimitive[] | undefined)[]} */
352
+ const args = new Array(specs.length)
353
+ for (let s = 0; s < specs.length; s++) {
354
+ const { node, star } = specs[s]
355
+ if (node.filter) {
356
+ const passes = await evaluateAll(node.filter, chunk, context)
357
+ filters[s] = passes
358
+ if (!star) {
359
+ // The buffered path filters the group before evaluating arguments,
360
+ // so only evaluate the argument for rows that pass the FILTER
361
+ /** @type {AsyncRow[]} */
362
+ const passingRows = []
363
+ /** @type {number[]} */
364
+ const passingIndices = []
365
+ for (let j = 0; j < chunk.length; j++) {
366
+ if (passes[j]) {
367
+ passingRows.push(chunk[j])
368
+ passingIndices.push(j)
369
+ }
370
+ }
371
+ const values = await evaluateAll(node.args[0], passingRows, context)
372
+ const spread = new Array(chunk.length).fill(null)
373
+ for (let k = 0; k < passingIndices.length; k++) {
374
+ spread[passingIndices[k]] = values[k]
375
+ }
376
+ args[s] = spread
377
+ }
378
+ } else {
379
+ args[s] = star ? undefined : await evaluateAll(node.args[0], chunk, context)
380
+ }
381
+ }
382
+
383
+ for (let j = 0; j < chunk.length; j++) {
384
+ const key = keyColumns
385
+ ? keyColumns.length === 1 ? keyify(keyColumns[0][j]) : keyify(...keyColumns.map(c => c[j]))
386
+ : true
387
+ let group = groups.get(key)
388
+ if (!group) {
389
+ group = {
390
+ firstRow: needsRow ? chunk[j] : undefined,
391
+ keyValues: keyColumns ? keyColumns.map(c => c[j]) : [],
392
+ accumulators: specs.map(spec => newAccumulator(spec.funcName, spec.node.distinct)),
393
+ }
394
+ groups.set(key, group)
395
+ }
396
+ for (let s = 0; s < specs.length; s++) {
397
+ const filter = filters[s]
398
+ if (filter && !filter[j]) continue
399
+ const spec = specs[s]
400
+ if (spec.star && spec.funcName === 'COUNT') {
401
+ group.accumulators[s].count++
402
+ } else {
403
+ const arg = args[s]
404
+ updateAccumulator(spec.funcName, group.accumulators[s], arg ? arg[j] : null)
405
+ }
406
+ }
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Consumes the child rows into per-group accumulators, holding at most one
412
+ * chunk of rows at a time. Returns undefined when aborted mid-collection so
413
+ * callers end the row stream silently, matching the buffered path.
414
+ *
415
+ * @param {object} options
416
+ * @param {QueryResults} options.child
417
+ * @param {ExprNode[]} options.groupBy
418
+ * @param {StreamingAggSpec[]} options.specs
419
+ * @param {boolean} options.needsRow
420
+ * @param {ExecuteContext} options.context
421
+ * @returns {Promise<Map<unknown, StreamingGroup> | undefined>}
422
+ */
423
+ async function accumulateGroups({ child, groupBy, specs, needsRow, context }) {
424
+ /** @type {Map<unknown, StreamingGroup>} */
425
+ const groups = new Map()
426
+ /** @type {AsyncRow[]} */
427
+ let chunk = []
428
+ for await (const row of child.rows()) {
429
+ chunk.push(row)
430
+ if (chunk.length >= CHUNK_SIZE) {
431
+ await accumulateChunk({ chunk, groupBy, specs, groups, needsRow, context })
432
+ chunk = []
433
+ await yieldToEventLoop()
434
+ if (context.signal?.aborted) return
435
+ }
436
+ }
437
+ if (chunk.length) {
438
+ await accumulateChunk({ chunk, groupBy, specs, groups, needsRow, context })
439
+ // An abort during the final partial chunk ends the stream silently,
440
+ // consistent with the full-chunk check above
441
+ if (context.signal?.aborted) return
442
+ }
443
+ context.signal?.throwIfAborted()
444
+ return groups
445
+ }
446
+
447
+ /**
448
+ * Builds a group's output row by substituting the group's finalized
449
+ * aggregate and group key values into the select expressions and evaluating
450
+ * them against the group's representative row (an empty row when no
451
+ * expression needs one).
452
+ *
453
+ * @param {object} options
454
+ * @param {SelectColumn[]} options.selectColumns
455
+ * @param {StreamingAggSpec[]} options.specs
456
+ * @param {Map<ExprNode, number>} options.keyRefs
457
+ * @param {StreamingGroup} options.group
458
+ * @param {ExecuteContext} options.context
459
+ * @returns {{ outputRow: AsyncRow, values: Map<ExprNode, SqlPrimitive> }}
460
+ */
461
+ function finalizeGroup({ selectColumns, specs, keyRefs, group, context }) {
462
+ const firstRow = group.firstRow ?? { columns: [], cells: {} }
463
+
464
+ /** @type {Map<ExprNode, SqlPrimitive>} */
465
+ const values = new Map()
466
+ for (let s = 0; s < specs.length; s++) {
467
+ values.set(specs[s].node, finalizeAccumulator(specs[s].funcName, group.accumulators[s]))
468
+ }
469
+ for (const [node, keyIndex] of keyRefs) {
470
+ values.set(node, group.keyValues[keyIndex])
471
+ }
472
+
473
+ /** @type {string[]} */
474
+ const columns = []
475
+ /** @type {AsyncCells} */
476
+ const cells = {}
477
+ for (const col of selectColumns) {
478
+ if (col.type === 'star') {
479
+ if (group.firstRow) {
480
+ const prefix = col.table ? `${col.table}.` : undefined
481
+ for (const key of firstRow.columns) {
482
+ if (prefix && !key.startsWith(prefix)) continue
483
+ const dotIndex = key.indexOf('.')
484
+ const outputKey = prefix ? key.substring(prefix.length) : dotIndex >= 0 ? key.substring(dotIndex + 1) : key
485
+ columns.push(outputKey)
486
+ cells[outputKey] = firstRow.cells[key]
487
+ }
488
+ }
489
+ } else {
490
+ const alias = col.alias ?? derivedAlias(col.expr)
491
+ const expr = substituteValues(col.expr, values)
492
+ columns.push(alias)
493
+ cells[alias] = () => evaluateExpr({ node: expr, row: firstRow, context })
494
+ }
495
+ }
496
+ /** @type {AsyncRow} */
497
+ const outputRow = { columns, cells }
498
+ return { outputRow, values }
499
+ }
500
+
501
+ /**
502
+ * Builds the row visible to HAVING and grouped ORDER BY: the group's
503
+ * representative columns plus the select output aliases, mirroring the
504
+ * buffered aggregate context row.
505
+ *
506
+ * @param {StreamingGroup} group
507
+ * @param {AsyncRow} outputRow
508
+ * @returns {AsyncRow}
509
+ */
510
+ function groupContextRow(group, outputRow) {
511
+ const firstRow = group.firstRow ?? { columns: [], cells: {} }
512
+ return {
513
+ columns: [...firstRow.columns, ...outputRow.columns],
514
+ cells: { ...firstRow.cells, ...outputRow.cells },
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Streaming GROUP BY execution: accumulates aggregates incrementally instead
520
+ * of buffering every input row, then applies HAVING and grouped ORDER BY
521
+ * against the finalized aggregate values.
522
+ *
523
+ * @param {object} options
524
+ * @param {HashAggregateNode} options.plan
525
+ * @param {StreamingAggPlan} options.streaming
526
+ * @param {QueryResults} options.child
527
+ * @param {ExecuteContext} options.context
528
+ * @returns {() => AsyncGenerator<AsyncRow>}
529
+ */
530
+ export function streamingHashAggregateRows({ plan, streaming, child, context }) {
531
+ const { specs, keyRefs, needsRow } = streaming
532
+ return async function* () {
533
+ const groups = await accumulateGroups({ child, groupBy: plan.groupBy, specs, needsRow, context })
534
+ if (!groups) return
535
+ const { orderBy, having } = plan
536
+
537
+ // Without ORDER BY, groups finalize and yield one at a time so output
538
+ // rows are never all held at once; sorting needs the full set below.
539
+ /** @type {{ row: AsyncRow, exprs: ExprNode[], outputRow: AsyncRow }[] | undefined} */
540
+ const entries = orderBy?.length ? [] : undefined
541
+ for (const group of groups.values()) {
542
+ const { outputRow, values } = finalizeGroup({ selectColumns: plan.columns, specs, keyRefs, group, context })
543
+ if (having) {
544
+ const passes = await evaluateExpr({
545
+ node: substituteValues(having, values),
546
+ row: groupContextRow(group, outputRow),
547
+ context,
548
+ })
549
+ if (!passes) continue
550
+ }
551
+ if (entries && orderBy) {
552
+ entries.push({
553
+ row: groupContextRow(group, outputRow),
554
+ exprs: orderBy.map(term => substituteValues(term.expr, values)),
555
+ outputRow,
556
+ })
557
+ } else {
558
+ yield outputRow
559
+ }
560
+ }
561
+
562
+ if (entries && orderBy) {
563
+ // The shared sorter evaluates later ORDER BY terms only within ties
564
+ // on earlier terms, so expensive sort keys are skipped when possible
565
+ const sorted = await sortEntriesByTerms({ entries, orderBy, context })
566
+ for (const { outputRow } of sorted) {
567
+ yield outputRow
568
+ }
569
+ }
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Streaming scalar aggregate execution: the whole input is one group,
575
+ * accumulated incrementally with bounded memory.
576
+ *
577
+ * @param {object} options
578
+ * @param {ScalarAggregateNode} options.plan
579
+ * @param {StreamingAggPlan} options.streaming
580
+ * @param {QueryResults} options.child
581
+ * @param {ExecuteContext} options.context
582
+ * @returns {() => AsyncGenerator<AsyncRow>}
583
+ */
584
+ export function streamingScalarAggregateRows({ plan, streaming, child, context }) {
585
+ const { specs, keyRefs, needsRow } = streaming
586
+ return async function* () {
587
+ const groups = await accumulateGroups({ child, groupBy: [], specs, needsRow, context })
588
+ if (!groups) return
589
+ /** @type {StreamingGroup} */
590
+ const group = groups.get(true) ?? { firstRow: undefined, keyValues: [], accumulators: specs.map(spec => newAccumulator(spec.funcName, spec.node.distinct)) }
591
+
592
+ const { outputRow, values } = finalizeGroup({ selectColumns: plan.columns, specs, keyRefs, group, context })
593
+ if (plan.having) {
594
+ const passes = await evaluateExpr({
595
+ node: substituteValues(plan.having, values),
596
+ row: groupContextRow(group, outputRow),
597
+ context,
598
+ })
599
+ if (!passes) return
600
+ }
601
+ yield outputRow
602
+ }
603
+ }
@@ -29,7 +29,7 @@ const YIELD_INTERVAL = 4000
29
29
  * @param {ExecuteContext} context
30
30
  * @returns {Promise<SqlPrimitive[]>}
31
31
  */
32
- async function evaluateAll(node, rows, context) {
32
+ export async function evaluateAll(node, rows, context) {
33
33
  /** @type {SqlPrimitive[]} */
34
34
  const results = new Array(rows.length)
35
35
  /** @type {Promise<SqlPrimitive>[]} */
@@ -226,7 +226,7 @@ export function extractColumns({ select, parentColumns }) {
226
226
  * @param {IdentifierNode[]} columns
227
227
  * @param {Set<string>} [aliases] - aliases to exclude from columns
228
228
  */
229
- function collectColumnsFromExpr(expr, columns, aliases) {
229
+ export function collectColumnsFromExpr(expr, columns, aliases) {
230
230
  if (!expr) return
231
231
  if (expr.type === 'identifier') {
232
232
  if (expr.prefix || !aliases?.has(expr.name)) {
package/src/plan/plan.js CHANGED
@@ -4,7 +4,7 @@ import { findAggregate } from '../validation/aggregates.js'
4
4
  import { ParseError } from '../validation/parseErrors.js'
5
5
  import { ColumnNotFoundError, TableNotFoundError } from '../validation/tables.js'
6
6
  import { validateNoIdentifiers, validateScan, validateTableRefs } from '../validation/tables.js'
7
- import { collectScopeColumns, extractColumns, fromAlias, inferSelectSourceColumns, inferStatementColumns, statementScope, tableFunctionColumnNames } from './columns.js'
7
+ import { collectColumnsFromExpr, collectScopeColumns, extractColumns, fromAlias, inferSelectSourceColumns, inferStatementColumns, statementScope, tableFunctionColumnNames } from './columns.js'
8
8
 
9
9
  /**
10
10
  * @import { AsyncDataSource, DerivedColumn, ExprNode, FromFunction, IdentifierNode, JoinClause, OrderByItem, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement, SetOperationStatement, Statement, WindowFunctionNode } from '../types.js'
@@ -208,6 +208,10 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
208
208
  const hints = {}
209
209
  const perTableColumns = extractColumns({ select: originalSelect, parentColumns })
210
210
  if (sourceAlias !== undefined) hints.columns = perTableColumns.get(sourceAlias)
211
+ // Capture what the parent reads from a FROM subquery before the reset
212
+ // below, so aggregate outputs it never reads can still be pruned when the
213
+ // parent reads nothing at all (an empty array here means exactly that).
214
+ const subqueryNeeds = select.from?.type === 'subquery' ? hints.columns : undefined
211
215
  // Empty columns array means no columns were referenced, but a FROM subquery
212
216
  // still needs its own columns (e.g. for DISTINCT). Treat empty as unrestricted.
213
217
  if (hints.columns?.length === 0 && select.from?.type === 'subquery') {
@@ -224,6 +228,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
224
228
  // Start with the data source (FROM clause)
225
229
  /** @type {QueryPlan} */
226
230
  let plan = planFrom({ select, ctePlans, cteColumns, hints, tables, outerScope })
231
+ pruneAggregateColumns(plan, subqueryNeeds)
227
232
 
228
233
  // Add JOINs
229
234
  if (select.joins.length) {
@@ -349,6 +354,38 @@ function pushLimitIntoSort(plan, limit, offset) {
349
354
  }
350
355
  }
351
356
 
357
+ /**
358
+ * Drops derived columns from a subquery's aggregate node when the parent
359
+ * query never reads them. The buffered path defers aggregate cells so unread
360
+ * outputs are never evaluated, but the streaming path accumulates every
361
+ * planned aggregate eagerly, so unread aggregates must be removed at plan
362
+ * time to preserve that lazy behavior. Columns referenced by HAVING or the
363
+ * aggregate's ORDER BY are kept: both evaluate against the group context
364
+ * row, which exposes output aliases. Descends only through Subquery and
365
+ * Limit wrappers; Sort and Distinct depend on the full column set, so
366
+ * anything below them is left untouched.
367
+ *
368
+ * @param {QueryPlan} plan
369
+ * @param {string[] | undefined} needed - output column names the parent reads
370
+ */
371
+ function pruneAggregateColumns(plan, needed) {
372
+ if (!needed) return
373
+ let node = plan
374
+ while (node.type === 'Subquery' || node.type === 'Limit') node = node.child
375
+ if (node.type !== 'HashAggregate' && node.type !== 'ScalarAggregate') return
376
+ /** @type {IdentifierNode[]} */
377
+ const identifiers = []
378
+ collectColumnsFromExpr(node.having, identifiers)
379
+ if (node.type === 'HashAggregate' && node.orderBy) {
380
+ for (const term of node.orderBy) collectColumnsFromExpr(term.expr, identifiers)
381
+ }
382
+ const keep = new Set(needed)
383
+ for (const { name } of identifiers) keep.add(name)
384
+ node.columns = node.columns.filter(col =>
385
+ col.type === 'star' || keep.has(col.alias ?? derivedAlias(col.expr))
386
+ )
387
+ }
388
+
352
389
  /**
353
390
  * @param {object} options
354
391
  * @param {SelectStatement} options.select
@@ -487,6 +524,7 @@ function planJoin({ left, joins, leftTable, ctePlans, cteColumns, perTableColumn
487
524
  outerScope,
488
525
  parentColumns: subColumns?.map(name => ({ type: 'identifier', name, positionStart: 0, positionEnd: 0 })),
489
526
  })
527
+ pruneAggregateColumns(subPlan, perTableColumns.get(rightTable))
490
528
  const availableColumns = inferStatementColumns({ stmt: join.subquery.query, cteColumns, tables })
491
529
  if (subColumns && availableColumns.length) {
492
530
  const missingColumn = subColumns.find(col => !availableColumns.includes(col))