squirreling 0.16.11 → 0.16.12

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.11",
3
+ "version": "0.16.12",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -5,6 +5,7 @@ import { finalizeAccumulator, newAccumulator, updateAccumulator } from './accumu
5
5
  import { executePlan, executeScan, selectColumnNames } from './execute.js'
6
6
  import { foldEvaluatedRows } from './fold.js'
7
7
  import { normalizeScanColumnResult } from './scanColumn.js'
8
+ import { scanFilter } from './scanFilter.js'
8
9
  import { sortEntriesByTerms } from './sort.js'
9
10
  import { planStreamingAggregates, streamingHashAggregateRows, streamingScalarAggregateRows } from './streamingAggregate.js'
10
11
  import { keyify } from './utils.js'
@@ -289,10 +290,12 @@ function tryColumnScanAggregate(plan, { tables, signal }) {
289
290
  // Child must be a direct table scan
290
291
  if (plan.child.type !== 'Scan') return
291
292
  const scanNode = plan.child
292
- const { limit, offset, where } = scanNode.hints
293
+ const { limit, offset } = scanNode.hints
293
294
 
294
295
  const table = tables[scanNode.table]
295
296
  if (!table?.scanColumn) return
297
+ const { where, residual } = scanFilter(scanNode, dataSourceColumns(table))
298
+ if (residual) return
296
299
 
297
300
  // COUNT(*) needs a physical column whose filtered chunk lengths can be
298
301
  // counted. Prefer a predicate/projection column, then any table column.
@@ -14,6 +14,7 @@ import { distinctBatches, filterBatches, limitBatches, projectExpressionBatches
14
14
  import { executeHashJoin, executeNestedLoopJoin, executePositionalJoin } from './join.js'
15
15
  import { referencesRowScope } from './rowScope.js'
16
16
  import { normalizeScanColumnResult } from './scanColumn.js'
17
+ import { scanFilter } from './scanFilter.js'
17
18
  import { executeSort } from './sort.js'
18
19
  import { executeSemiJoin } from './semiJoin.js'
19
20
  import { addBounds, minBounds, stableRowKey } from './utils.js'
@@ -317,35 +318,51 @@ export function executeScan(plan, context, existingColumnResult) {
317
318
  const { tables, signal } = context
318
319
  const table = validateTable({ ...plan, tables })
319
320
  validateScan({ ...plan, tables })
320
- const hasLimitOffset = plan.hints.limit !== undefined || plan.hints.offset // 0 offset is noop
321
+ const { where, residual } = scanFilter(plan, dataSourceColumns(table))
322
+ const hints = { ...plan.hints, where }
323
+ if (residual) {
324
+ // Keep range and Top-K outside the scan until the engine residual runs.
325
+ delete hints.limit
326
+ delete hints.offset
327
+ }
328
+ const hasLimitOffset = hints.limit !== undefined || hints.offset // 0 offset is noop
321
329
  const scanContext = { ...context, scope: [plan.alias ?? plan.table] }
322
330
 
323
331
  if (!existingColumnResult && table.prepareScan && table.schema) {
324
- const prepared = table.prepareScan(scanRequest(plan, table.schema, scanContext))
325
- return executePreparedScan({ plan, prepared, context: scanContext, table })
332
+ // Derive column demands from the original predicate, including fields
333
+ // needed by engine residuals, but send only the source-local predicate.
334
+ const request = scanRequest(plan, table.schema, scanContext)
335
+ request.filter = where
336
+ if (residual) {
337
+ delete request.limit
338
+ delete request.offset
339
+ delete request.topK
340
+ }
341
+ const prepared = table.prepareScan(request)
342
+ return finish(executePreparedScan({ plan: { ...plan, hints }, prepared, context: scanContext, table }))
326
343
  }
327
344
 
328
345
  // Fast path: single column scan. As with scan(), hints the source did not
329
346
  // apply are handled by the engine over the returned column values.
330
- const scanColumnOptions = plan.hints.columns?.length === 1
331
- ? plan.hints.where
347
+ const scanColumnOptions = hints.columns?.length === 1
348
+ ? hints.where
332
349
  // Do not push a filtered range until WHERE is known to be applied: an
333
350
  // older source may ignore WHERE but eagerly apply LIMIT/OFFSET.
334
- ? { column: plan.hints.columns[0], where: plan.hints.where, signal }
335
- : { column: plan.hints.columns[0], ...plan.hints, signal }
351
+ ? { column: hints.columns[0], where: hints.where, signal }
352
+ : { column: hints.columns[0], ...hints, signal }
336
353
  : undefined
337
354
  const columnResult = existingColumnResult ?? (table.scanColumn && scanColumnOptions
338
355
  ? normalizeScanColumnResult(table.scanColumn(scanColumnOptions), scanColumnOptions)
339
356
  : undefined)
340
357
  if (columnResult && scanColumnOptions) {
341
- const column = plan.hints.columns[0]
342
- const scanRows = computeScanRows(table.numRows, plan.hints.limit, plan.hints.offset)
358
+ const column = hints.columns[0]
359
+ const scanRows = computeScanRows(table.numRows, hints.limit, hints.offset)
343
360
  const columns = [column]
344
- const appliedLimitOffset = plan.hints.where
361
+ const appliedLimitOffset = hints.where
345
362
  ? false
346
363
  : columnResult.appliedLimitOffset
347
- const residualFilter = plan.hints.where && !columnResult.appliedWhere
348
- ? compileBatchExpression(plan.hints.where, columns)
364
+ const residualFilter = hints.where && !columnResult.appliedWhere
365
+ ? compileBatchExpression(hints.where, columns)
349
366
  : undefined
350
367
 
351
368
  /** @returns {AsyncIterable<AsyncBatch>} */
@@ -353,28 +370,28 @@ export function executeScan(plan, context, existingColumnResult) {
353
370
  /** @type {AsyncIterable<AsyncBatch>} */
354
371
  let batches = columnBatches(columnResult.chunks(), signal)
355
372
  if (residualFilter) {
356
- const targetRows = plan.hints.limit === undefined
373
+ const targetRows = hints.limit === undefined
357
374
  ? undefined
358
- : plan.hints.limit + (plan.hints.offset ?? 0)
375
+ : hints.limit + (hints.offset ?? 0)
359
376
  batches = filterBatches(batches, residualFilter, signal, targetRows)
360
377
  }
361
378
  if (!appliedLimitOffset && hasLimitOffset) {
362
- batches = limitBatches(batches, plan.hints.limit, plan.hints.offset, signal)
379
+ batches = limitBatches(batches, hints.limit, hints.offset, signal)
363
380
  }
364
381
  return batches
365
382
  }
366
383
 
367
- const canUseBatches = !plan.hints.where || columnResult.appliedWhere || residualFilter !== undefined
384
+ const canUseBatches = !hints.where || columnResult.appliedWhere || residualFilter !== undefined
368
385
  if (canUseBatches) {
369
- return batchResult({
386
+ return finish(batchResult({
370
387
  columns,
371
- numRows: plan.hints.where ? undefined : scanRows,
388
+ numRows: hints.where ? undefined : scanRows,
372
389
  maxRows: scanRows,
373
390
  batches: makeBatches,
374
391
  signal,
375
- })
392
+ }))
376
393
  }
377
- return {
394
+ return finish({
378
395
  columns,
379
396
  numRows: undefined,
380
397
  maxRows: scanRows,
@@ -395,10 +412,10 @@ export function executeScan(plan, context, existingColumnResult) {
395
412
  }
396
413
  })()
397
414
 
398
- result = filterRows(result, plan.hints.where, scanContext, plan.hints.limit)
415
+ result = filterRows(result, hints.where, scanContext, hints.limit)
399
416
  // Filtered LIMIT/OFFSET was intentionally not passed to scanColumn.
400
417
  if (!appliedLimitOffset && hasLimitOffset) {
401
- result = limitRows(result, plan.hints.limit, plan.hints.offset, signal)
418
+ result = limitRows(result, hints.limit, hints.offset, signal)
402
419
  }
403
420
 
404
421
  yield* result
@@ -406,7 +423,7 @@ export function executeScan(plan, context, existingColumnResult) {
406
423
  // the abort so a truncated scan is not mistaken for a complete one
407
424
  signal?.throwIfAborted()
408
425
  },
409
- }
426
+ })
410
427
  }
411
428
 
412
429
  if (!table.scan) {
@@ -414,30 +431,30 @@ export function executeScan(plan, context, existingColumnResult) {
414
431
  }
415
432
 
416
433
  // do the scan
417
- const scanResult = table.scan({ ...plan.hints, signal })
434
+ const scanResult = table.scan({ ...hints, signal })
418
435
  const { appliedWhere, appliedLimitOffset } = scanResult
419
436
 
420
437
  // Applied limit/offset without applied where is invalid
421
- if (!appliedWhere && appliedLimitOffset && plan.hints.where && hasLimitOffset) {
438
+ if (!appliedWhere && appliedLimitOffset && hints.where && hasLimitOffset) {
422
439
  throw new Error(`Data source "${plan.table}" applied limit/offset without applying where`)
423
440
  }
424
441
 
425
- const scanRows = computeScanRows(table.numRows, plan.hints.limit, plan.hints.offset)
426
- return {
427
- columns: plan.hints.columns ?? dataSourceColumns(table),
428
- numRows: !plan.hints.where ? scanRows : undefined,
442
+ const scanRows = computeScanRows(table.numRows, hints.limit, hints.offset)
443
+ return finish({
444
+ columns: hints.columns ?? dataSourceColumns(table),
445
+ numRows: !hints.where ? scanRows : undefined,
429
446
  maxRows: scanRows,
430
447
  async *rows() {
431
448
  let result = scanResult.rows()
432
449
 
433
450
  // Apply WHERE if data source did not
434
- if (!appliedWhere && plan.hints.where) {
435
- result = filterRows(result, plan.hints.where, scanContext, plan.hints.limit)
451
+ if (!appliedWhere && hints.where) {
452
+ result = filterRows(result, hints.where, scanContext, hints.limit)
436
453
  }
437
454
 
438
455
  // Apply LIMIT/OFFSET if data source did not
439
456
  if (!appliedLimitOffset && hasLimitOffset) {
440
- result = limitRows(result, plan.hints.limit, plan.hints.offset, signal)
457
+ result = limitRows(result, hints.limit, hints.offset, signal)
441
458
  }
442
459
 
443
460
  yield* result
@@ -446,6 +463,24 @@ export function executeScan(plan, context, existingColumnResult) {
446
463
  // the abort so a truncated scan is not mistaken for a complete one
447
464
  signal?.throwIfAborted()
448
465
  },
466
+ })
467
+
468
+ /**
469
+ * Apply engine-only predicates and ranges after any source execution path.
470
+ * @param {QueryResults} source
471
+ * @returns {QueryResults}
472
+ */
473
+ function finish(source) {
474
+ if (!residual) return source
475
+ const { limit, offset } = plan.hints
476
+ return {
477
+ columns: source.columns,
478
+ maxRows: computeScanRows(source.maxRows, limit, offset),
479
+ async *rows() {
480
+ const filtered = filterRows(source.rows(), residual, scanContext, limit)
481
+ yield* limitRows(filtered, limit, offset, signal)
482
+ },
483
+ }
449
484
  }
450
485
  }
451
486
 
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @import { ExprNode } from '../types.js'
3
+ * @import { ScanNode } from '../plan/types.js'
4
+ */
5
+
6
+ /**
7
+ * Translate local conjuncts into physical column names. Keep subqueries and
8
+ * references outside this scan in the engine. Match scope names exactly, as
9
+ * expression evaluation does, including quoted identifiers and case.
10
+ *
11
+ * @param {ScanNode} plan
12
+ * @param {string[]} columns
13
+ * @returns {{ where?: ExprNode, residual?: ExprNode }}
14
+ */
15
+ export function scanFilter(plan, columns) {
16
+ const scope = plan.alias ?? plan.table
17
+
18
+ /**
19
+ * @param {ExprNode} node
20
+ * @returns {{ where?: ExprNode, residual?: ExprNode }}
21
+ */
22
+ function split(node) {
23
+ if (!node) return {}
24
+ if (node.type === 'binary' && node.op === 'AND') {
25
+ const left = split(node.left)
26
+ const right = split(node.right)
27
+ return {
28
+ where: combine(left.where, right.where),
29
+ residual: combine(left.residual, right.residual),
30
+ }
31
+ }
32
+ let safe = true
33
+ /**
34
+ * @param {ExprNode} expr
35
+ * @returns {ExprNode}
36
+ */
37
+ function rewrite(expr) {
38
+ if (!expr) return expr
39
+ if (expr.type === 'identifier') {
40
+ if (expr.prefix && expr.prefix !== scope || !columns.includes(expr.name) ||
41
+ expr.prefix && columns.includes(`${expr.prefix}.${expr.name}`)) {
42
+ safe = false
43
+ return expr
44
+ }
45
+ if (!expr.prefix) return expr
46
+ const bare = { ...expr }
47
+ delete bare.prefix
48
+ return bare
49
+ }
50
+ if (expr.type === 'literal' || expr.type === 'interval') return expr
51
+ if (expr.type === 'binary') return { ...expr, left: rewrite(expr.left), right: rewrite(expr.right) }
52
+ if (expr.type === 'unary') return { ...expr, argument: rewrite(expr.argument) }
53
+ if (expr.type === 'cast') return { ...expr, expr: rewrite(expr.expr) }
54
+ if (expr.type === 'function') return { ...expr, args: expr.args.map(rewrite), ...expr.filter ? { filter: rewrite(expr.filter) } : {} }
55
+ if (expr.type === 'in valuelist') return { ...expr, expr: rewrite(expr.expr), values: expr.values.map(rewrite) }
56
+ if (expr.type === 'subscript') return { ...expr, expr: rewrite(expr.expr), index: rewrite(expr.index) }
57
+ if (expr.type === 'case') return {
58
+ ...expr,
59
+ caseExpr: rewrite(expr.caseExpr),
60
+ whenClauses: expr.whenClauses.map(w => ({ ...w, condition: rewrite(w.condition), result: rewrite(w.result) })),
61
+ elseResult: rewrite(expr.elseResult),
62
+ }
63
+ safe = false
64
+ return expr
65
+ }
66
+ const where = rewrite(node)
67
+ return safe ? { where } : { residual: node }
68
+ }
69
+ return split(plan.hints.where)
70
+ }
71
+
72
+ /**
73
+ * @param {ExprNode} left
74
+ * @param {ExprNode} right
75
+ * @returns {ExprNode}
76
+ */
77
+ function combine(left, right) {
78
+ if (!left) return right
79
+ if (!right) return left
80
+ return { type: 'binary', op: 'AND', left, right, positionStart: left.positionStart, positionEnd: right.positionEnd }
81
+ }
package/src/types.d.ts CHANGED
@@ -145,6 +145,7 @@ export interface ScanTopK {
145
145
  export interface ScanRequest {
146
146
  columns: readonly ColumnDemand[]
147
147
  topK?: ScanTopK
148
+ /** Source-local predicate: identifiers use bare physical column names. */
148
149
  filter?: ExprNode
149
150
  limit?: number
150
151
  offset?: number
@@ -271,7 +272,8 @@ export interface ScanResults {
271
272
  */
272
273
  export interface ScanOptions {
273
274
  columns?: string[] // columns needed (undefined means all columns)
274
- where?: ExprNode // where clause
275
+ /** Source-local predicate: identifiers use bare physical column names. */
276
+ where?: ExprNode
275
277
  // important: only apply limit/offset if where is fully applied by the data source
276
278
  // otherwise, the data source must return at least enough rows to ensure the engine
277
279
  // can apply limit/offset correctly after filtering
@@ -285,6 +287,7 @@ export interface ScanOptions {
285
287
  */
286
288
  export interface ScanColumnOptions {
287
289
  column: string
290
+ /** Source-local predicate: identifiers use bare physical column names. */
288
291
  where?: ExprNode
289
292
  limit?: number
290
293
  offset?: number