Skip to content
1 change: 1 addition & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ The main flow inside `executeSql({ tables, query, functions, signal })`:
**Executor** (`src/execute/`):
- `execute.js` - `executeSql()` and `executePlan()`, dispatches to node-specific executors
- `join.js`, `aggregates.js`, `sort.js` - executors for join, aggregate, and sort nodes
- `streamingAggregate.js` - bounded-memory aggregation for streamable aggregates (COUNT, COUNTIF, SUM, AVG, MIN, MAX); other aggregates buffer rows in `aggregates.js`

**Expression Evaluator** (`src/expression/`):
- `evaluate.js` - evaluates expression AST nodes against rows
Expand Down
89 changes: 89 additions & 0 deletions src/execute/accumulator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { keyify } from './utils.js'

/**
* @import { SqlPrimitive } from '../types.js'
*/

/**
* Incremental state for one streamable aggregate. Shared by the streaming
* aggregate executor and the scanColumn fast path so COUNT/COUNTIF/SUM/AVG/
* MIN/MAX fold semantics live in one place.
*
* @typedef {{
* count: number,
* sum: number,
* min: SqlPrimitive,
* max: SqlPrimitive,
* seen: Set<unknown> | null,
* }} Accumulator
*/

/**
* @param {string} funcName
* @param {boolean} [distinct]
* @returns {Accumulator}
*/
export function newAccumulator(funcName, distinct) {
return {
count: 0,
sum: 0,
min: null,
max: null,
seen: funcName === 'COUNT' && distinct ? new Set() : null,
}
}

/**
* Folds one value into an accumulator, matching the buffered semantics in
* evaluate.js: COUNT counts non-null, COUNTIF counts truthy, MIN/MAX compare
* raw values, SUM/AVG only accumulate finite numbers.
*
* @param {string} funcName
* @param {Accumulator} acc
* @param {SqlPrimitive} value
*/
export function updateAccumulator(funcName, acc, value) {
switch (funcName) {
case 'COUNT':
if (value == null) break
if (acc.seen) acc.seen.add(keyify(value))
else acc.count++
break
case 'COUNTIF':
if (value) acc.count++
break
case 'MIN':
if (value != null && (acc.min === null || value < acc.min)) acc.min = value
break
case 'MAX':
if (value != null && (acc.max === null || value > acc.max)) acc.max = value
break
default: { // SUM, AVG
if (value == null) break
const num = Number(value)
if (Number.isFinite(num)) {
acc.sum += num
acc.count++
}
}
}
}

/**
* Reduces an accumulator to its final aggregate value.
*
* @param {string} funcName
* @param {Accumulator} acc
* @returns {SqlPrimitive}
*/
export function finalizeAccumulator(funcName, acc) {
switch (funcName) {
case 'COUNT': return acc.seen ? acc.seen.size : acc.count
case 'COUNTIF': return acc.count
case 'SUM': return acc.count === 0 ? null : acc.sum
case 'AVG': return acc.count === 0 ? null : acc.sum / acc.count
case 'MIN': return acc.min
case 'MAX': return acc.max
default: return null
}
}
83 changes: 24 additions & 59 deletions src/execute/aggregates.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { derivedAlias } from '../expression/alias.js'
import { evaluateExpr } from '../expression/evaluate.js'
import { finalizeAccumulator, newAccumulator, updateAccumulator } from './accumulator.js'
import { executePlan, selectColumnNames } from './execute.js'
import { sortEntriesByTerms } from './sort.js'
import { planStreamingAggregates, streamingHashAggregateRows, streamingScalarAggregateRows } from './streamingAggregate.js'
import { keyify } from './utils.js'
import { yieldToEventLoop } from './yield.js'

Expand Down Expand Up @@ -80,6 +82,14 @@ function aggregateContextRow(group, aggregateRow) {
*/
export function executeHashAggregate(plan, context) {
const child = executePlan({ plan: plan.child, context })
const streaming = planStreamingAggregates(plan, child.columns)
if (streaming) {
return {
columns: selectColumnNames(plan.columns, child.columns),
maxRows: child.maxRows,
rows: streamingHashAggregateRows({ plan, streaming, child, context }),
}
}
return {
columns: selectColumnNames(plan.columns, child.columns),
maxRows: child.maxRows,
Expand Down Expand Up @@ -196,6 +206,15 @@ export function executeScalarAggregate(plan, context) {
}

const child = executePlan({ plan: plan.child, context })
const streaming = planStreamingAggregates(plan, child.columns)
if (streaming) {
return {
columns: selectColumnNames(plan.columns, child.columns),
numRows: plan.having ? undefined : 1,
maxRows: 1,
rows: streamingScalarAggregateRows({ plan, streaming, child, context }),
}
}
return {
columns: selectColumnNames(plan.columns, child.columns),
numRows: plan.having ? undefined : 1,
Expand Down Expand Up @@ -344,17 +363,6 @@ function extractColumnAggSpec({ expr, alias }) {
}
}

/**
* @typedef {{
* spec: ColumnAggSpec,
* count: number,
* sum: number,
* min: SqlPrimitive,
* max: SqlPrimitive,
* seen: Set<unknown> | null,
* }} AggAccumulator
*/

/**
* Scans a column once and computes every aggregate over it in a single pass.
* All specs share the one scanColumn walk, so MIN(x)/MAX(x)/AVG(x) decode x once.
Expand All @@ -371,67 +379,24 @@ async function scanColumnGroup({ table, specs, limit, offset, signal }) {
const { column } = specs[0]
const values = table.scanColumn({ column, limit, offset, signal })

/** @type {AggAccumulator[]} */
const accs = specs.map(spec => /** @type {AggAccumulator} */ ({
spec,
count: 0,
sum: 0,
min: null,
max: null,
seen: spec.funcName === 'COUNT' && spec.distinct ? new Set() : null,
}))
const accs = specs.map(spec => ({ spec, acc: newAccumulator(spec.funcName, spec.distinct) }))

for await (const chunk of values) {
signal?.throwIfAborted()
for (let i = 0; i < chunk.length; i++) {
const v = chunk[i]
if (v == null) continue
for (const acc of accs) {
switch (acc.spec.funcName) {
case 'COUNT':
if (acc.seen) acc.seen.add(keyify(v))
else acc.count++
break
case 'MIN':
if (acc.min === null || v < acc.min) acc.min = v
break
case 'MAX':
if (acc.max === null || v > acc.max) acc.max = v
break
default: { // SUM, AVG
const num = Number(v)
if (Number.isFinite(num)) {
acc.sum += num
acc.count++
}
}
}
for (const { spec, acc } of accs) {
updateAccumulator(spec.funcName, acc, v)
}
}
}
signal?.throwIfAborted()

/** @type {Map<string, SqlPrimitive>} */
const result = new Map()
for (const acc of accs) {
result.set(acc.spec.alias, finalizeAgg(acc))
for (const { spec, acc } of accs) {
result.set(spec.alias, finalizeAccumulator(spec.funcName, acc))
}
return result
}

/**
* Reduces one accumulator to its final aggregate value.
*
* @param {AggAccumulator} acc
* @returns {SqlPrimitive}
*/
function finalizeAgg(acc) {
switch (acc.spec.funcName) {
case 'COUNT': return acc.seen ? acc.seen.size : acc.count
case 'SUM': return acc.count === 0 ? null : acc.sum
case 'AVG': return acc.count === 0 ? null : acc.sum / acc.count
case 'MIN': return acc.min
case 'MAX': return acc.max
default: return null
}
}
9 changes: 7 additions & 2 deletions src/execute/sort.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,21 @@ import { executePlan } from './execute.js'
import { compareForTerm } from './utils.js'

/**
* @import { AsyncRow, ExecuteContext, OrderByItem, QueryResults, SqlPrimitive } from '../types.js'
* @import { AsyncRow, ExecuteContext, ExprNode, OrderByItem, QueryResults, SqlPrimitive } from '../types.js'
* @import { SortNode } from '../plan/types.js'
*/

const MAX_CHUNK = 256

/**
* When exprs is set, exprs[i] is evaluated for ORDER BY term i instead of the
* term's own expression, so callers can pre-substitute per-entry values (for
* example, finalized aggregates) while keeping tie-aware term evaluation.
*
* @typedef {{
* row: AsyncRow,
* rows?: AsyncRow[],
* exprs?: ExprNode[],
* }} SortEntry
*/

Expand Down Expand Up @@ -63,7 +68,7 @@ export async function sortEntriesByTerms({ entries, orderBy, context, cacheValue
const chunk = missing.slice(start, start + chunkSize)
const values = await Promise.all(chunk.map(idx =>
evaluateExpr({
node: term.expr,
node: entries[idx].exprs?.[orderByIdx] ?? term.expr,
row: entries[idx].row,
rows: entries[idx].rows,
context,
Expand Down
Loading