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 +1 -1
- package/src/execute/accumulator.js +89 -0
- package/src/execute/aggregates.js +24 -59
- package/src/execute/sort.js +7 -2
- package/src/execute/streamingAggregate.js +603 -0
- package/src/expression/evaluate.js +1 -1
- package/src/plan/columns.js +1 -1
- package/src/plan/plan.js +39 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
}
|
package/src/execute/sort.js
CHANGED
|
@@ -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>[]} */
|
package/src/plan/columns.js
CHANGED
|
@@ -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))
|