squirreling 0.16.5 → 0.16.7

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.5",
3
+ "version": "0.16.7",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -40,12 +40,12 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@eslint/js": "10.0.1",
43
- "@types/node": "26.5.0",
44
- "@vitest/coverage-v8": "5.0.0",
45
- "eslint": "10.10.0",
46
- "eslint-plugin-jsdoc": "64.3.6",
43
+ "@types/node": "26.6.2",
44
+ "@vitest/coverage-v8": "5.0.2",
45
+ "eslint": "10.11.0",
46
+ "eslint-plugin-jsdoc": "64.5.4",
47
47
  "globals": "17.12.0",
48
48
  "typescript": "7.0.2",
49
- "vitest": "5.0.0"
49
+ "vitest": "5.0.2"
50
50
  }
51
51
  }
@@ -21,7 +21,7 @@ import { yieldToEventLoop } from './yield.js'
21
21
 
22
22
  /**
23
23
  * @import { BatchProjection, CompiledBatchExpression } from '../internalTypes.js'
24
- * @import { AsyncBatch, AsyncCells, AsyncDataSource, AsyncRow, ColumnDemand, ColumnVector, DerivedColumn, ExecuteContext, ExecuteSqlOptions, ExprNode, IdentifierNode, PreparedScan, QueryResults, RelationSchema, ScanRequest, SelectColumn, SqlPrimitive, Statement } from '../types.js'
24
+ * @import { AsyncBatch, AsyncCells, AsyncDataSource, AsyncRow, ColumnDemand, ColumnVector, DerivedColumn, ExecuteContext, ExecuteSqlOptions, ExprNode, IdentifierNode, NumericArray, PreparedScan, QueryResults, RelationSchema, ScanColumnResults, ScanRequest, ScanTopK, SelectColumn, SqlPrimitive, Statement } from '../types.js'
25
25
  * @import { CountNode, DistinctNode, FilterNode, LimitNode, ProjectNode, QueryPlan, ScanNode, SetOperationNode, TableFunctionNode } from '../plan/types.js'
26
26
  */
27
27
 
@@ -277,7 +277,7 @@ export function selectColumnNames(selectColumns, childColumns) {
277
277
  /**
278
278
  * @param {ScanNode} plan
279
279
  * @param {ExecuteContext} context
280
- * @param {import('../types.js').ScanColumnResults} [existingColumnResult]
280
+ * @param {ScanColumnResults} [existingColumnResult]
281
281
  * @returns {QueryResults}
282
282
  */
283
283
  export function executeScan(plan, context, existingColumnResult) {
@@ -539,12 +539,27 @@ function scanRequest(plan, schema, context) {
539
539
  mode: predicate ? 'required' : 'deferred',
540
540
  }
541
541
  })
542
- return {
542
+ /** @type {ScanRequest} */
543
+ const request = {
543
544
  columns,
544
545
  filter: plan.hints.where,
545
546
  limit: plan.hints.limit,
546
547
  offset: plan.hints.offset,
547
548
  }
549
+ if (plan.topK) {
550
+ /** @type {ScanTopK['orderBy'][number][]} */
551
+ const orderBy = []
552
+ for (const term of plan.topK.orderBy) {
553
+ const { expr } = term
554
+ if (expr.type !== 'identifier' ||
555
+ expr.prefix && expr.prefix !== (plan.alias ?? plan.table)) return request
556
+ const field = schema.fields.find(field => field.name === expr.name)
557
+ if (!field) return request
558
+ orderBy.push({ field: field.id, direction: term.direction, nulls: term.nulls ?? 'FIRST' })
559
+ }
560
+ if (orderBy.length) request.topK = { orderBy, limit: plan.topK.limit }
561
+ }
562
+ return request
548
563
  }
549
564
 
550
565
  /**
@@ -1016,7 +1031,7 @@ function vectorFromChunk(chunk) {
1016
1031
 
1017
1032
  /**
1018
1033
  * @param {ArrayLike<SqlPrimitive>} values
1019
- * @returns {values is import('../types.js').NumericArray}
1034
+ * @returns {values is NumericArray}
1020
1035
  */
1021
1036
  function isNumericArray(values) {
1022
1037
  return values instanceof Int8Array
@@ -128,8 +128,16 @@ export async function sortEntriesByTerms({ entries, orderBy, context, cacheValue
128
128
  * @returns {QueryResults}
129
129
  */
130
130
  export function executeSort(plan, context) {
131
- const child = executePlan({ plan: plan.child, context })
132
131
  const { topK } = plan
132
+ let childPlan = plan.child
133
+ // Only a directly adjacent scan can prune candidates for this sort. Never
134
+ // cross filters, joins, aggregates or windows, or change an existing scan
135
+ // slice. Copy the node: CTE plans can be shared by other consumers.
136
+ if (Number.isSafeInteger(topK) && topK > 0 && childPlan.type === 'Scan' &&
137
+ childPlan.hints.limit === undefined && !childPlan.hints.offset) {
138
+ childPlan = { ...childPlan, topK: { orderBy: plan.orderBy, limit: topK } }
139
+ }
140
+ const child = executePlan({ plan: childPlan, context })
133
141
  // With a LIMIT bound pushed into the sort, keep at most this many buffered
134
142
  // rows: periodically sort and discard everything past topK, so memory is
135
143
  // bounded by the limit instead of the input size.
package/src/index.d.ts CHANGED
@@ -30,6 +30,7 @@ export type {
30
30
  ScanOptions,
31
31
  ScanProperties,
32
32
  ScanRequest,
33
+ ScanTopK,
33
34
  ScanResidual,
34
35
  ScanResults,
35
36
  SelectStatement,
package/src/plan/plan.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { dataSourceColumns } from '../backend/dataSource.js'
1
2
  import { derivedAlias } from '../expression/alias.js'
2
3
  import { parseSql } from '../parse/parse.js'
3
4
  import { findAggregate } from '../validation/aggregates.js'
@@ -238,6 +239,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
238
239
  /** @type {QueryPlan} */
239
240
  let plan = planFrom({ select, ctePlans, cteColumns, hints, tables, outerScope })
240
241
  pruneAggregateColumns(plan, subqueryNeeds)
242
+ const directFrom = plan.type === 'Scan' && plan.hints === hints
241
243
 
242
244
  // Add JOINs
243
245
  if (select.joins.length) {
@@ -248,8 +250,13 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
248
250
  const isOwnScan = plan.type === 'Scan' && plan.hints === hints
249
251
 
250
252
  // Add WHERE filter when the scan didn't receive it
251
- if (select.where && !isOwnScan) {
252
- plan = { type: 'Filter', condition: select.where, child: plan }
253
+ let { where } = select
254
+ if (directFrom && select.joins.length === 1 && !outerScope?.length &&
255
+ !ctePlans?.has(select.joins[0].table?.toLowerCase())) {
256
+ where = pushJoinPredicates(plan, where, tables)
257
+ }
258
+ if (where && !isOwnScan) {
259
+ plan = { type: 'Filter', condition: where, child: plan }
253
260
  }
254
261
 
255
262
  if (useGrouping) {
@@ -826,6 +833,109 @@ function collectConjuncts(node, out) {
826
833
  out.push(node)
827
834
  }
828
835
 
836
+ /**
837
+ * Push total, table-local comparisons into fresh scans of a direct INNER
838
+ * equijoin. Scan execution already enforces hints a source cannot apply.
839
+ * Validate ON before moving anything; an unsafe ON also blocks WHERE
840
+ * pushdown. Safe ON terms can move even when WHERE must remain above the
841
+ * join, since ON already excludes those pairs before WHERE is evaluated.
842
+ *
843
+ * @param {QueryPlan} plan
844
+ * @param {ExprNode | undefined} where
845
+ * @param {Record<string, AsyncDataSource>} [tables]
846
+ * @returns {ExprNode | undefined}
847
+ */
848
+ function pushJoinPredicates(plan, where, tables) {
849
+ if (!tables || plan.type !== 'HashJoin' || plan.joinType !== 'INNER' ||
850
+ plan.left.type !== 'Scan' || plan.right.type !== 'Scan') return where
851
+ const scans = [plan.left, plan.right]
852
+ if (scans.some(scan => scan.hints.where || scan.hints.limit !== undefined || scan.hints.offset)) return where
853
+ const aliases = scans.map(scan => scan.alias ?? scan.table)
854
+ if (aliases[0] === aliases[1]) return where
855
+ const columns = scans.map(scan => dataSourceColumns(tables[scan.table]))
856
+ // A prefix that also names a struct column is not an unambiguous alias.
857
+ if (columns.some(names => aliases.some(alias => names.includes(alias)))) return where
858
+
859
+ /**
860
+ * @param {ExprNode} expr
861
+ * @returns {'left' | 'right' | 'literal' | 'unsafe'}
862
+ */
863
+ function owner(expr) {
864
+ if (expr.type === 'literal') return 'literal'
865
+ if (expr.type !== 'identifier' || !expr.prefix) return 'unsafe'
866
+ const index = aliases.indexOf(expr.prefix)
867
+ if (index < 0 || !columns[index].includes(expr.name)) return 'unsafe'
868
+ return index === 0 ? 'left' : 'right'
869
+ }
870
+
871
+ // Avoid changing the evaluation of unresolved or unqualified ON keys.
872
+ if (plan.leftKeys.some(key => owner(key) !== 'left') || plan.rightKeys.some(key => owner(key) !== 'right')) return where
873
+ /**
874
+ * @param {ExprNode[]} conjuncts
875
+ * @returns {Array<'left' | 'right' | 'residual'> | undefined}
876
+ */
877
+ function classify(conjuncts) {
878
+ /** @type {Array<'left' | 'right' | 'residual'>} */
879
+ const targets = []
880
+ for (const expr of conjuncts) {
881
+ if (expr.type !== 'binary' || !['=', '==', '!=', '<>', '<', '<=', '>', '>='].includes(expr.op)) return undefined
882
+ const left = owner(expr.left)
883
+ const right = owner(expr.right)
884
+ if (left === 'unsafe' || right === 'unsafe') return undefined
885
+ // Only column/literal terms move; cross-table comparisons remain residual.
886
+ targets.push(left !== 'literal' && right === 'literal' ? left : right !== 'literal' && left === 'literal' ? right : 'residual')
887
+ }
888
+ return targets
889
+ }
890
+
891
+ /**
892
+ * @param {ExprNode[]} conjuncts
893
+ * @param {Array<'left' | 'right' | 'residual'>} targets
894
+ * @returns {ExprNode | undefined}
895
+ */
896
+ function push(conjuncts, targets) {
897
+ /** @type {ExprNode | undefined} */
898
+ let residual
899
+ for (let i = 0; i < conjuncts.length; i++) {
900
+ const target = targets[i]
901
+ if (target === 'residual') {
902
+ residual = andPredicate(residual, conjuncts[i])
903
+ } else {
904
+ const scan = target === 'left' ? scans[0] : scans[1]
905
+ scan.hints = { ...scan.hints, where: andPredicate(scan.hints.where, conjuncts[i]) }
906
+ }
907
+ }
908
+ return residual
909
+ }
910
+
911
+ /** @type {ExprNode[]} */
912
+ const onConjuncts = []
913
+ if (plan.residual) collectConjuncts(plan.residual, onConjuncts)
914
+ const onTargets = classify(onConjuncts)
915
+ if (!onTargets) return where
916
+ const onResidual = push(onConjuncts, onTargets)
917
+ if (onResidual) plan.residual = onResidual
918
+ else delete plan.residual
919
+
920
+ if (!where) return undefined
921
+ /** @type {ExprNode[]} */
922
+ const whereConjuncts = []
923
+ collectConjuncts(where, whereConjuncts)
924
+ const whereTargets = classify(whereConjuncts)
925
+ return whereTargets ? push(whereConjuncts, whereTargets) : where
926
+ }
927
+
928
+ /**
929
+ * @param {ExprNode | undefined} left
930
+ * @param {ExprNode} right
931
+ * @returns {ExprNode}
932
+ */
933
+ function andPredicate(left, right) {
934
+ return left
935
+ ? { type: 'binary', op: 'AND', left, right, positionStart: left.positionStart, positionEnd: right.positionEnd }
936
+ : right
937
+ }
938
+
829
939
  /**
830
940
  * Returns the (leftKey, rightKey) pair for an equi conjunct, oriented so
831
941
  * leftKey references the left input and rightKey references the right input.
@@ -25,6 +25,9 @@ export interface ScanNode {
25
25
  table: string
26
26
  alias?: string
27
27
  hints: ScanOptions
28
+ // Execution-local hint from an immediately enclosing bounded Sort. Kept
29
+ // separate from scan LIMIT/OFFSET, which select an unordered input slice.
30
+ topK?: { orderBy: OrderByItem[], limit: number }
28
31
  }
29
32
 
30
33
  // Source for FROM-less SELECT like `SELECT 1`. Yields exactly one empty row.
package/src/types.d.ts CHANGED
@@ -126,8 +126,25 @@ export interface ColumnDemand {
126
126
  mode: 'required' | 'deferred'
127
127
  }
128
128
 
129
+ /**
130
+ * Optional candidate-pruning hint for an ordered slice. A source may ignore
131
+ * it, or omit rows proven unable to enter the first `limit` rows after the
132
+ * scan's filter. Preserve all boundary ties and the relative input order of
133
+ * retained rows. Return an unordered superset of the winners, not an arbitrary
134
+ * `limit` rows. The engine retains the final sort and slice.
135
+ */
136
+ export interface ScanTopK {
137
+ orderBy: readonly {
138
+ field: number
139
+ direction: 'ASC' | 'DESC'
140
+ nulls: 'FIRST' | 'LAST'
141
+ }[]
142
+ limit: number // Includes the outer OFFSET.
143
+ }
144
+
129
145
  export interface ScanRequest {
130
146
  columns: readonly ColumnDemand[]
147
+ topK?: ScanTopK
131
148
  filter?: ExprNode
132
149
  limit?: number
133
150
  offset?: number