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 +6 -6
- package/src/execute/execute.js +19 -4
- package/src/execute/sort.js +9 -1
- package/src/index.d.ts +1 -0
- package/src/plan/plan.js +112 -2
- package/src/plan/types.d.ts +3 -0
- package/src/types.d.ts +17 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squirreling",
|
|
3
|
-
"version": "0.16.
|
|
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.
|
|
44
|
-
"@vitest/coverage-v8": "5.0.
|
|
45
|
-
"eslint": "10.
|
|
46
|
-
"eslint-plugin-jsdoc": "64.
|
|
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.
|
|
49
|
+
"vitest": "5.0.2"
|
|
50
50
|
}
|
|
51
51
|
}
|
package/src/execute/execute.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
1034
|
+
* @returns {values is NumericArray}
|
|
1020
1035
|
*/
|
|
1021
1036
|
function isNumericArray(values) {
|
|
1022
1037
|
return values instanceof Int8Array
|
package/src/execute/sort.js
CHANGED
|
@@ -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
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
|
-
|
|
252
|
-
|
|
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.
|
package/src/plan/types.d.ts
CHANGED
|
@@ -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
|