squirreling 0.16.13 → 0.16.14

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.16.13",
3
+ "version": "0.16.14",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -43,7 +43,7 @@
43
43
  "@types/node": "26.6.5",
44
44
  "@vitest/coverage-v8": "5.0.5",
45
45
  "eslint": "10.13.0",
46
- "eslint-plugin-jsdoc": "65.4.0",
46
+ "eslint-plugin-jsdoc": "65.4.1",
47
47
  "globals": "17.13.0",
48
48
  "typescript": "7.0.2",
49
49
  "vitest": "5.0.5"
@@ -193,6 +193,18 @@ export function executeHashAggregate(plan, context) {
193
193
  * @returns {QueryResults}
194
194
  */
195
195
  export function executeScalarAggregate(plan, context) {
196
+ if (context.deferScans && plan.child.type === 'Scan') {
197
+ const child = executeScan(plan.child, context)
198
+ return {
199
+ columns: selectColumnNames(plan.columns, child.columns),
200
+ numRows: plan.having ? undefined : 1,
201
+ maxRows: 1,
202
+ async *rows() {
203
+ context.signal?.throwIfAborted()
204
+ yield* executeScalarAggregate(plan, { ...context, deferScans: false }).rows()
205
+ },
206
+ }
207
+ }
196
208
  // Fast path: use scanColumn when available
197
209
  const columnScan = tryColumnScanAggregate(plan, context)
198
210
  if (columnScan?.rows) {
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @import { ExprNode, SqlPrimitive } from '../types.js'
3
+ * @import { QueryPlan } from '../plan/types.js'
4
+ */
5
+
6
+ // Bound filter memory and match the session-index pruning limit.
7
+ const MAX_DYNAMIC_FILTER_KEYS = 256
8
+
9
+ /**
10
+ * Bounded, per-column key sets. Once a column exceeds the cap, release it and
11
+ * stop collecting that column. Unsupported values disable that column too.
12
+ *
13
+ * @param {number} count
14
+ * @param {boolean} [hash] - preserve the hash join's serialized key equivalences
15
+ * @returns {{ add(values: SqlPrimitive[]): void, apply(plan: QueryPlan, keys: ExprNode[]): QueryPlan }}
16
+ */
17
+ export function dynamicFilter(count, hash = false) {
18
+ /** @type {(Set<SqlPrimitive> | undefined)[]} */
19
+ const sets = Array.from({ length: count }, () => new Set())
20
+ let active = true
21
+ return {
22
+ /**
23
+ * @param {SqlPrimitive[]} values
24
+ * @returns {void}
25
+ */
26
+ add(values) {
27
+ if (!active) return
28
+ // Hash tuple serialization uses delimiters and serializes objects. Do
29
+ // not prune when those encodings can collide across column boundaries.
30
+ if (hash && values.some(value => typeof value === 'object' && value !== null ||
31
+ typeof value === 'string' && (value.includes('|') || ['[', '{', '"'].includes(value[0]) || value === 'null'))) {
32
+ sets.fill(undefined)
33
+ active = false
34
+ return
35
+ }
36
+ for (let i = 0; i < sets.length; i++) {
37
+ const set = sets[i]
38
+ const value = values[i]
39
+ if (!set || value == null) continue
40
+ if (!['string', 'number', 'bigint', 'boolean'].includes(typeof value) ||
41
+ typeof value === 'number' && !Number.isFinite(value)) {
42
+ sets[i] = undefined
43
+ continue
44
+ }
45
+ set.add(value)
46
+ if (hash && count > 1) {
47
+ if (typeof value === 'boolean') set.add(String(value))
48
+ if (value === 'true' || value === 'false') set.add(value === 'true')
49
+ }
50
+ if (set.size > MAX_DYNAMIC_FILTER_KEYS) sets[i] = undefined
51
+ }
52
+ active = sets.some(Boolean)
53
+ },
54
+ /**
55
+ * @param {QueryPlan} plan
56
+ * @param {ExprNode[]} keys
57
+ * @returns {QueryPlan}
58
+ */
59
+ apply(plan, keys) {
60
+ for (let i = 0; i < keys.length; i++) {
61
+ const set = sets[i]
62
+ if (set?.size) plan = pushFilter(plan, keys[i], [...set])
63
+ }
64
+ return plan
65
+ },
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Only traverse operators that preserve the meaning of a physical column.
71
+ * Limits, aggregates, windows and projections are deliberate barriers.
72
+ *
73
+ * @param {QueryPlan} plan
74
+ * @param {ExprNode} key
75
+ * @param {SqlPrimitive[]} values
76
+ * @returns {QueryPlan}
77
+ */
78
+ function pushFilter(plan, key, values) {
79
+ if (key?.type !== 'identifier') return plan
80
+ if (plan.type === 'Scan') {
81
+ if (key.prefix && key.prefix !== (plan.alias ?? plan.table) ||
82
+ plan.hints.limit !== undefined || plan.hints.offset || plan.topK) return plan
83
+ const position = { positionStart: key.positionStart, positionEnd: key.positionEnd }
84
+ /** @type {ExprNode} */
85
+ const filter = {
86
+ type: 'in valuelist', expr: key, ...position,
87
+ values: values.map(value => ({ type: 'literal', value, ...position })),
88
+ }
89
+ /** @type {ExprNode} */
90
+ const where = plan.hints.where
91
+ ? { type: 'binary', op: 'AND', left: plan.hints.where, right: filter, ...position }
92
+ : filter
93
+ return { ...plan, hints: { ...plan.hints, where } }
94
+ }
95
+ if (plan.type === 'Filter' || plan.type === 'Sort' && !plan.topK || plan.type === 'Distinct') {
96
+ return { ...plan, child: pushFilter(plan.child, key, values) }
97
+ }
98
+ if (plan.type === 'HashJoin' && key.prefix) {
99
+ // Restrict only preserved inputs of outer joins; restricting an optional
100
+ // input could turn a formerly matched row into a new null-extended row.
101
+ const left = plan.joinType === 'INNER' || plan.joinType === 'LEFT'
102
+ ? pushFilter(plan.left, key, values) : plan.left
103
+ const right = plan.joinType === 'INNER' || plan.joinType === 'RIGHT'
104
+ ? pushFilter(plan.right, key, values) : plan.right
105
+ return { ...plan, left, right }
106
+ }
107
+ return plan
108
+ }
109
+
110
+ /**
111
+ * Selectivity heuristic when exact filtered cardinalities are unavailable.
112
+ * Count conjuncts so a date plus a LIKE wins over a lone part-type predicate.
113
+ *
114
+ * @param {QueryPlan} plan
115
+ * @returns {number}
116
+ */
117
+ export function filterScore(plan) {
118
+ if (plan.type === 'Scan') return conjuncts(plan.hints.where)
119
+ if (plan.type === 'Filter') return conjuncts(plan.condition) + filterScore(plan.child)
120
+ if (plan.type === 'HashJoin' && plan.joinType === 'INNER') return Math.max(filterScore(plan.left), filterScore(plan.right))
121
+ return 0
122
+ }
123
+
124
+ /**
125
+ * @param {ExprNode} expr
126
+ * @returns {number}
127
+ */
128
+ function conjuncts(expr) {
129
+ return !expr ? 0 : expr.type === 'binary' && expr.op === 'AND'
130
+ ? conjuncts(expr.left) + conjuncts(expr.right) : 1
131
+ }
@@ -1,5 +1,5 @@
1
1
  import { selectedRowCount } from '../backend/batch.js'
2
- import { batchesToRows } from '../backend/batchAdapters.js'
2
+ import { batchesToRows, rowsToBatches } from '../backend/batchAdapters.js'
3
3
  import { dataSourceColumns, memorySource } from '../backend/dataSource.js'
4
4
  import { derivedAlias } from '../expression/alias.js'
5
5
  import { compileBatchExpression } from '../expression/batch.js'
@@ -318,6 +318,37 @@ export function executeScan(plan, context, existingColumnResult) {
318
318
  const { tables, signal } = context
319
319
  const table = validateTable({ ...plan, tables })
320
320
  validateScan({ ...plan, tables })
321
+ if (context.deferScans && !existingColumnResult) {
322
+ const size = computeScanRows(table.numRows, plan.hints.limit, plan.hints.offset)
323
+ const columns = plan.hints.columns ?? dataSourceColumns(table)
324
+ /** @type {QueryResults} */
325
+ const deferred = {
326
+ columns,
327
+ numRows: plan.hints.where ? undefined : size,
328
+ maxRows: size,
329
+ async *rows() {
330
+ context.signal?.throwIfAborted()
331
+ yield* executeScan(plan, { ...context, deferScans: false }).rows()
332
+ },
333
+ }
334
+ // Preserve native batch execution above a deferred input. Legacy fallback
335
+ // remains at the source boundary if a source declines its column path.
336
+ if (table.prepareScan && table.schema || table.scanColumn && plan.hints.columns?.length === 1) {
337
+ deferred.batches = async function* deferredBatches() {
338
+ context.signal?.throwIfAborted()
339
+ const result = executeScan(plan, { ...context, deferScans: false })
340
+ if (result.batches) {
341
+ // Prepared sources may return extra fields or a different order.
342
+ // Align vectors with the metadata exposed before opening the scan.
343
+ const indexes = columns.map(column => result.columns.indexOf(column))
344
+ for await (const batch of result.batches()) {
345
+ yield { ...batch, columns: indexes.map(index => batch.columns[index]) }
346
+ }
347
+ } else yield* rowsToBatches(result.rows(), columns, { signal })
348
+ }
349
+ }
350
+ return deferred
351
+ }
321
352
  const { where, residual } = scanFilter(plan, dataSourceColumns(table))
322
353
  const hints = { ...plan.hints, where }
323
354
  if (residual) {
@@ -1,6 +1,7 @@
1
1
  import { evaluateExpr } from '../expression/evaluate.js'
2
2
  import { keyify, maxBounds } from './utils.js'
3
3
  import { executePlan } from './execute.js'
4
+ import { dynamicFilter, filterScore } from './dynamicFilter.js'
4
5
  import { yieldToEventLoop } from './yield.js'
5
6
 
6
7
  /**
@@ -222,14 +223,18 @@ export function executePositionalJoin(plan, context) {
222
223
  * @returns {QueryResults}
223
224
  */
224
225
  export function executeHashJoin(plan, context) {
225
- const left = executePlan({ plan: plan.left, context })
226
- const right = executePlan({ plan: plan.right, context })
227
- // Build the hash table on the smaller side when both sizes are known,
228
- // so joining a small table against a large one buffers the small one.
226
+ const left = executePlan({ plan: plan.left, context: { ...context, deferScans: true } })
227
+ const right = executePlan({ plan: plan.right, context: { ...context, deferScans: true } })
228
+ // Prefer exact sizes, then predicate selectivity, then upper bounds.
229
+ // A filtered scan's table size is only an upper bound, not its cardinality.
229
230
  // Swapping reorders output rows, which SQL leaves unspecified.
230
231
  const leftSize = left.numRows ?? left.maxRows
231
232
  const rightSize = right.numRows ?? right.maxRows
232
- const swap = leftSize !== undefined && rightSize !== undefined && leftSize < rightSize
233
+ const swap = left.numRows !== undefined && right.numRows !== undefined
234
+ ? leftSize < rightSize
235
+ : filterScore(plan.left) !== filterScore(plan.right)
236
+ ? filterScore(plan.left) > filterScore(plan.right)
237
+ : leftSize !== undefined && rightSize !== undefined && leftSize < rightSize
233
238
  return {
234
239
  columns: mergeColumnNames(left.columns, right.columns, plan.leftAlias, plan.rightAlias),
235
240
  async *rows() {
@@ -237,7 +242,7 @@ export function executeHashJoin(plan, context) {
237
242
  const rightTable = plan.rightAlias
238
243
  const { residual } = plan
239
244
  const build = swap ? left : right
240
- const probe = swap ? right : left
245
+ let probe = swap ? right : left
241
246
  const buildKeys = swap ? plan.leftKeys : plan.rightKeys
242
247
  const probeKeys = swap ? plan.rightKeys : plan.leftKeys
243
248
  // Which sides must also emit their unmatched rows
@@ -260,6 +265,7 @@ export function executeHashJoin(plan, context) {
260
265
  // otherwise rows with NULL join keys are released immediately.
261
266
  /** @type {Map<string | number | bigint | boolean, AsyncRow[]>} */
262
267
  const hashMap = new Map()
268
+ const filters = dynamicFilter(buildKeys.length, true)
263
269
  /** @type {AsyncRow[] | undefined} */
264
270
  const buildRows = buildOuter ? [] : undefined
265
271
  /** @type {string[]} */
@@ -281,6 +287,7 @@ export function executeHashJoin(plan, context) {
281
287
  // SQL semantics: NULL never equals anything, so a row with any NULL
282
288
  // join key is excluded from the hash table.
283
289
  if (keyValues.some(v => v == null)) continue
290
+ if (!probeOuter) filters.add(keyValues)
284
291
  const key = keyify(...keyValues)
285
292
  let bucket = hashMap.get(key)
286
293
  if (!bucket) {
@@ -295,8 +302,14 @@ export function executeHashJoin(plan, context) {
295
302
  /** @type {Set<AsyncRow> | undefined} */
296
303
  const matchedBuildRows = buildOuter ? new Set() : undefined
297
304
 
305
+ if (!probeOuter && !hashMap.size && !buildOuter) return
306
+ if (!probeOuter && hashMap.size) {
307
+ const filtered = filters.apply(swap ? plan.right : plan.left, probeKeys)
308
+ probe = executePlan({ plan: filtered, context: { ...context, deferScans: true } })
309
+ }
310
+
298
311
  // Probe phase: stream the other side
299
- for await (const probeRow of probe.rows()) {
312
+ for await (const probeRow of !probeOuter && !hashMap.size ? [] : probe.rows()) {
300
313
  context.signal?.throwIfAborted()
301
314
 
302
315
  if (!probeCols) {
@@ -1,5 +1,6 @@
1
1
  import { evaluateExpr } from '../expression/evaluate.js'
2
2
  import { executePlan } from './execute.js'
3
+ import { dynamicFilter } from './dynamicFilter.js'
3
4
  import { sqlEquals } from './utils.js'
4
5
  import { yieldToEventLoop } from './yield.js'
5
6
 
@@ -19,7 +20,7 @@ import { yieldToEventLoop } from './yield.js'
19
20
  */
20
21
  export function executeSemiJoin(plan, context) {
21
22
  if (plan.keys) return executeExistsJoin(plan, context)
22
- const left = executePlan({ plan: plan.left, context })
23
+ const left = executePlan({ plan: plan.left, context: { ...context, deferScans: true } })
23
24
  return {
24
25
  columns: left.columns,
25
26
  maxRows: left.maxRows,
@@ -28,29 +29,30 @@ export function executeSemiJoin(plan, context) {
28
29
  const values = new Map()
29
30
  let hasNull = false
30
31
  let count = 0
31
- let built = false
32
32
  let rowIndex = 0
33
- for await (const row of left.rows()) {
33
+ const filters = dynamicFilter(1)
34
+ for await (const inner of executePlan({ plan: plan.right, context }).rows()) {
35
+ context.signal?.throwIfAborted()
36
+ if (++count % 4000 === 0) await yieldToEventLoop()
37
+ const value = await inner.cells[inner.columns[0]]()
38
+ if (value == null) hasNull = true
39
+ else {
40
+ const type = typeof value
41
+ let group = values.get(type)
42
+ if (!group) values.set(type, group = new Set())
43
+ group.add(value)
44
+ if (!plan.not) filters.add([value])
45
+ }
46
+ }
47
+ if (!plan.not && !values.size || plan.not && hasNull) return
48
+ const probe = executePlan({
49
+ plan: plan.not ? plan.left : filters.apply(plan.left, [plan.expr]),
50
+ context: { ...context, deferScans: true },
51
+ })
52
+ for await (const row of probe.rows()) {
34
53
  context.signal?.throwIfAborted()
35
54
  if (++rowIndex % 4000 === 0) await yieldToEventLoop()
36
55
  const value = await evaluateExpr({ node: plan.expr, row, rowIndex, context })
37
- if (!built) {
38
- for await (const inner of executePlan({ plan: plan.right, context }).rows()) {
39
- if (++count % 4000 === 0) {
40
- await yieldToEventLoop()
41
- context.signal?.throwIfAborted()
42
- }
43
- const value = await inner.cells[inner.columns[0]]()
44
- if (value == null) hasNull = true
45
- else {
46
- const type = typeof value
47
- let group = values.get(type)
48
- if (!group) values.set(type, group = new Set())
49
- group.add(value)
50
- }
51
- }
52
- built = true
53
- }
54
56
  const matched = count === 0 ? false : value == null ? null : contains(values, value) ? true : hasNull ? null : false
55
57
  if (matched !== null && matched !== plan.not) yield row
56
58
  }
@@ -87,36 +89,39 @@ function contains(values, value) {
87
89
  * @returns {QueryResults}
88
90
  */
89
91
  function executeExistsJoin(plan, context) {
90
- const left = executePlan({ plan: plan.left, context })
92
+ const left = executePlan({ plan: plan.left, context: { ...context, deferScans: true } })
91
93
  return {
92
94
  columns: left.columns,
93
95
  maxRows: left.maxRows,
94
96
  async *rows() {
95
97
  /** @type {KeyTrie} */
96
98
  const root = { groups: new Map() }
97
- let built = false
98
99
  let count = 0
99
- for await (const row of left.rows()) {
100
+ const filters = dynamicFilter(plan.keys?.length ?? 0)
101
+ for await (const inner of executePlan({ plan: plan.right, context }).rows()) {
100
102
  context.signal?.throwIfAborted()
101
103
  if (++count % 4000 === 0) await yieldToEventLoop()
102
- if (!built) {
103
- for await (const inner of executePlan({ plan: plan.right, context }).rows()) {
104
- context.signal?.throwIfAborted()
105
- if (++count % 4000 === 0) await yieldToEventLoop()
106
- const values = await Promise.all(inner.columns.map(col => inner.cells[col]()))
107
- if (values.some(value => value == null)) continue
108
- let node = root
109
- for (const value of values) {
110
- const type = typeof value
111
- let group = node.groups.get(type)
112
- if (!group) node.groups.set(type, group = new Map())
113
- let child = group.get(value)
114
- if (!child) group.set(value, child = { groups: new Map() })
115
- node = child
116
- }
117
- }
118
- built = true
104
+ const values = await Promise.all(inner.columns.map(col => inner.cells[col]()))
105
+ if (values.some(value => value == null)) continue
106
+ if (!plan.not) filters.add(values)
107
+ let node = root
108
+ for (const value of values) {
109
+ const type = typeof value
110
+ let group = node.groups.get(type)
111
+ if (!group) node.groups.set(type, group = new Map())
112
+ let child = group.get(value)
113
+ if (!child) group.set(value, child = { groups: new Map() })
114
+ node = child
119
115
  }
116
+ }
117
+ if (!plan.not && !root.groups.size) return
118
+ const probe = executePlan({
119
+ plan: plan.not ? plan.left : filters.apply(plan.left, plan.keys ?? []),
120
+ context: { ...context, deferScans: true },
121
+ })
122
+ for await (const row of probe.rows()) {
123
+ context.signal?.throwIfAborted()
124
+ if (++count % 4000 === 0) await yieldToEventLoop()
120
125
  const values = await Promise.all((plan.keys ?? []).map(node => evaluateExpr({ node, row, context })))
121
126
  const matched = !values.some(value => value == null) && matchesKeys(root, values, 0)
122
127
  if (matched !== plan.not) yield row
package/src/types.d.ts CHANGED
@@ -199,6 +199,8 @@ export interface PlanSqlOptions {
199
199
 
200
200
  // executePlan(plan, context)
201
201
  export interface ExecuteContext {
202
+ // Delay opening join input scans until their rows are consumed.
203
+ deferScans?: boolean
202
204
  tables: Record<string, AsyncDataSource>
203
205
  functions?: Record<string, UserDefinedFunction>
204
206
  signal?: AbortSignal