squirreling 0.16.9 → 0.16.10
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 +5 -5
- package/src/execute/execute.js +3 -0
- package/src/execute/semiJoin.js +77 -0
- package/src/plan/plan.js +85 -24
- package/src/plan/types.d.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squirreling",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.10",
|
|
4
4
|
"description": "Squirreling Async SQL Engine",
|
|
5
5
|
"author": "Hyperparam",
|
|
6
6
|
"homepage": "https://hyperparam.app",
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@eslint/js": "10.0.1",
|
|
43
43
|
"@types/node": "26.6.5",
|
|
44
|
-
"@vitest/coverage-v8": "5.0.
|
|
45
|
-
"eslint": "10.
|
|
46
|
-
"eslint-plugin-jsdoc": "65.
|
|
44
|
+
"@vitest/coverage-v8": "5.0.5",
|
|
45
|
+
"eslint": "10.13.0",
|
|
46
|
+
"eslint-plugin-jsdoc": "65.4.0",
|
|
47
47
|
"globals": "17.13.0",
|
|
48
48
|
"typescript": "7.0.2",
|
|
49
|
-
"vitest": "5.0.
|
|
49
|
+
"vitest": "5.0.5"
|
|
50
50
|
}
|
|
51
51
|
}
|
package/src/execute/execute.js
CHANGED
|
@@ -15,6 +15,7 @@ import { executeHashJoin, executeNestedLoopJoin, executePositionalJoin } from '.
|
|
|
15
15
|
import { referencesRowScope } from './rowScope.js'
|
|
16
16
|
import { normalizeScanColumnResult } from './scanColumn.js'
|
|
17
17
|
import { executeSort } from './sort.js'
|
|
18
|
+
import { executeSemiJoin } from './semiJoin.js'
|
|
18
19
|
import { addBounds, minBounds, stableRowKey } from './utils.js'
|
|
19
20
|
import { executeWindow } from './window.js'
|
|
20
21
|
import { yieldToEventLoop } from './yield.js'
|
|
@@ -108,6 +109,8 @@ export function executePlan({ plan, context }) {
|
|
|
108
109
|
return executeFilter(plan, context)
|
|
109
110
|
} else if (plan.type === 'Project') {
|
|
110
111
|
return executeProject(plan, context)
|
|
112
|
+
} else if (plan.type === 'SemiJoin') {
|
|
113
|
+
return executeSemiJoin(plan, context)
|
|
111
114
|
} else if (plan.type === 'HashJoin') {
|
|
112
115
|
return executeHashJoin(plan, context)
|
|
113
116
|
} else if (plan.type === 'NestedLoopJoin') {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { evaluateExpr } from '../expression/evaluate.js'
|
|
2
|
+
import { executePlan } from './execute.js'
|
|
3
|
+
import { sqlEquals } from './utils.js'
|
|
4
|
+
import { yieldToEventLoop } from './yield.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @import { ExecuteContext, QueryResults, SqlPrimitive } from '../types.js'
|
|
8
|
+
* @import { SemiJoinNode } from '../plan/types.js'
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Executes WHERE IN / NOT IN with one build of the right input per iteration.
|
|
13
|
+
* The membership set belongs to this operator, never to the context or AST.
|
|
14
|
+
*
|
|
15
|
+
* @param {SemiJoinNode} plan
|
|
16
|
+
* @param {ExecuteContext} context
|
|
17
|
+
* @returns {QueryResults}
|
|
18
|
+
*/
|
|
19
|
+
export function executeSemiJoin(plan, context) {
|
|
20
|
+
const left = executePlan({ plan: plan.left, context })
|
|
21
|
+
return {
|
|
22
|
+
columns: left.columns,
|
|
23
|
+
maxRows: left.maxRows,
|
|
24
|
+
async *rows() {
|
|
25
|
+
/** @type {Map<string, Set<SqlPrimitive>>} */
|
|
26
|
+
const values = new Map()
|
|
27
|
+
let hasNull = false
|
|
28
|
+
let count = 0
|
|
29
|
+
let built = false
|
|
30
|
+
let rowIndex = 0
|
|
31
|
+
for await (const row of left.rows()) {
|
|
32
|
+
context.signal?.throwIfAborted()
|
|
33
|
+
if (++rowIndex % 4000 === 0) await yieldToEventLoop()
|
|
34
|
+
const value = await evaluateExpr({ node: plan.expr, row, rowIndex, context })
|
|
35
|
+
if (!built) {
|
|
36
|
+
for await (const inner of executePlan({ plan: plan.right, context }).rows()) {
|
|
37
|
+
if (++count % 4000 === 0) {
|
|
38
|
+
await yieldToEventLoop()
|
|
39
|
+
context.signal?.throwIfAborted()
|
|
40
|
+
}
|
|
41
|
+
const value = await inner.cells[inner.columns[0]]()
|
|
42
|
+
if (value == null) hasNull = true
|
|
43
|
+
else {
|
|
44
|
+
const type = typeof value
|
|
45
|
+
let group = values.get(type)
|
|
46
|
+
if (!group) values.set(type, group = new Set())
|
|
47
|
+
group.add(value)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
built = true
|
|
51
|
+
}
|
|
52
|
+
const matched = count === 0 ? false : value == null ? null : contains(values, value) ? true : hasNull ? null : false
|
|
53
|
+
if (matched !== null && matched !== plan.not) yield row
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Same-type primitives use a Set. Cross-type values and Dates retain the
|
|
61
|
+
* evaluator's SQL equality, including coercion and timestamp comparison.
|
|
62
|
+
*
|
|
63
|
+
* @param {Map<string, Set<SqlPrimitive>>} values
|
|
64
|
+
* @param {SqlPrimitive} value
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
function contains(values, value) {
|
|
68
|
+
const type = typeof value
|
|
69
|
+
if (type !== 'object' && !(typeof value === 'number' && Number.isNaN(value)) && values.get(type)?.has(value)) return true
|
|
70
|
+
for (const [valueType, group] of values) {
|
|
71
|
+
if (valueType === type && type !== 'object') continue
|
|
72
|
+
for (const candidate of group) {
|
|
73
|
+
if (sqlEquals(value, candidate)) return true
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return false
|
|
77
|
+
}
|
package/src/plan/plan.js
CHANGED
|
@@ -9,7 +9,7 @@ import { collectColumnsFromExpr, collectScopeColumns, extractColumns, fromAlias,
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* @import { AsyncDataSource, DerivedColumn, ExprNode, FromFunction, IdentifierNode, JoinClause, OrderByItem, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement, SetOperationStatement, Statement, WindowFunctionNode } from '../types.js'
|
|
12
|
-
* @import { HashJoinNode, QueryPlan, TableFunctionNode, WindowSpec } from './types.js'
|
|
12
|
+
* @import { HashJoinNode, QueryPlan, SemiJoinNode, TableFunctionNode, WindowSpec } from './types.js'
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -230,9 +230,46 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
230
230
|
if (hints.columns?.length === 0 && select.from?.type === 'subquery') {
|
|
231
231
|
hints.columns = undefined
|
|
232
232
|
}
|
|
233
|
+
// A WHERE membership test is a semi-join, not a row-by-row expression
|
|
234
|
+
// subquery. Keep the original SELECT for column demand analysis above.
|
|
235
|
+
/** @type {Omit<SemiJoinNode, 'left'>[]} */
|
|
236
|
+
const semiJoins = []
|
|
237
|
+
if (select.where && !outerScope?.length) {
|
|
238
|
+
/** @type {ExprNode[]} */
|
|
239
|
+
const conjuncts = []
|
|
240
|
+
collectConjuncts(select.where, conjuncts)
|
|
241
|
+
/** @type {ExprNode | undefined} */
|
|
242
|
+
let residual
|
|
243
|
+
for (const conjunct of conjuncts) {
|
|
244
|
+
const not = conjunct.type === 'unary' && conjunct.op === 'NOT'
|
|
245
|
+
const expr = not ? conjunct.argument : conjunct
|
|
246
|
+
if (expr.type !== 'in') {
|
|
247
|
+
residual = andPredicate(residual, conjunct)
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
/** @type {IdentifierNode[]} */
|
|
251
|
+
const correlated = []
|
|
252
|
+
collectColumnsFromExpr({ type: 'subquery', subquery: expr.subquery, positionStart: expr.positionStart, positionEnd: expr.positionEnd }, correlated, undefined, {
|
|
253
|
+
tables, cteColumns, outerAliases: new Set(scopeAliases),
|
|
254
|
+
})
|
|
255
|
+
if (correlated.length) {
|
|
256
|
+
residual = andPredicate(residual, conjunct)
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
const right = planStatement({ stmt: expr.subquery, tables, ctePlans, cteColumns })
|
|
260
|
+
const scope = statementScope(expr.subquery)
|
|
261
|
+
semiJoins.push({
|
|
262
|
+
type: 'SemiJoin',
|
|
263
|
+
expr: expr.expr,
|
|
264
|
+
not,
|
|
265
|
+
right: scope ? { type: 'Subquery', child: right, scope } : right,
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
select = { ...select, where: residual }
|
|
269
|
+
}
|
|
233
270
|
if (!select.joins.length) {
|
|
234
271
|
hints.where = select.where
|
|
235
|
-
if (!needsBuffering && !select.distinct) {
|
|
272
|
+
if (!needsBuffering && !select.distinct && !semiJoins.length) {
|
|
236
273
|
hints.limit = select.limit
|
|
237
274
|
hints.offset = select.offset
|
|
238
275
|
}
|
|
@@ -262,6 +299,8 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
262
299
|
plan = { type: 'Filter', condition: where, child: plan }
|
|
263
300
|
}
|
|
264
301
|
|
|
302
|
+
for (const semiJoin of semiJoins) plan = { ...semiJoin, left: plan }
|
|
303
|
+
|
|
265
304
|
if (useGrouping) {
|
|
266
305
|
// Aggregation path: GROUP BY or scalar aggregate
|
|
267
306
|
// HAVING is integrated into aggregate nodes for access to group context
|
|
@@ -346,7 +385,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
346
385
|
plan = { type: 'Distinct', child: plan }
|
|
347
386
|
}
|
|
348
387
|
|
|
349
|
-
if (!(isOwnScan && !needsBuffering && !select.distinct) && (select.limit !== undefined || select.offset)) {
|
|
388
|
+
if (!(isOwnScan && !needsBuffering && !select.distinct && !semiJoins.length) && (select.limit !== undefined || select.offset)) {
|
|
350
389
|
if (select.limit !== undefined) pushLimitIntoSort(plan, select.limit, select.offset)
|
|
351
390
|
plan = { type: 'Limit', limit: select.limit, offset: select.offset, child: plan }
|
|
352
391
|
}
|
|
@@ -837,11 +876,10 @@ function collectConjuncts(node, out) {
|
|
|
837
876
|
}
|
|
838
877
|
|
|
839
878
|
/**
|
|
840
|
-
* Push
|
|
841
|
-
* equijoin. Scan execution
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
* join, since ON already excludes those pairs before WHERE is evaluated.
|
|
879
|
+
* Push each table-local conjunct into fresh scans of a direct INNER
|
|
880
|
+
* equijoin. Scan execution enforces hints a source cannot apply. Unresolved
|
|
881
|
+
* and cross-table conjuncts remain residual predicates. Predicate evaluation
|
|
882
|
+
* order and error short-circuiting are not guaranteed.
|
|
845
883
|
*
|
|
846
884
|
* @param {QueryPlan} plan
|
|
847
885
|
* @param {ExprNode | undefined} where
|
|
@@ -865,30 +903,54 @@ function pushJoinPredicates(plan, where, tables) {
|
|
|
865
903
|
*/
|
|
866
904
|
function owner(expr) {
|
|
867
905
|
if (expr.type === 'literal') return 'literal'
|
|
868
|
-
if (expr.type !== 'identifier'
|
|
869
|
-
const
|
|
906
|
+
if (expr.type !== 'identifier') return 'unsafe'
|
|
907
|
+
const matches = columns.map((names, i) => names.includes(expr.name) ? i : -1).filter(i => i >= 0)
|
|
908
|
+
const index = expr.prefix ? aliases.indexOf(expr.prefix) : matches.length === 1 ? matches[0] : -1
|
|
870
909
|
if (index < 0 || !columns[index].includes(expr.name)) return 'unsafe'
|
|
871
910
|
return index === 0 ? 'left' : 'right'
|
|
872
911
|
}
|
|
873
912
|
|
|
874
913
|
// Avoid changing the evaluation of unresolved or unqualified ON keys.
|
|
875
914
|
if (plan.leftKeys.some(key => owner(key) !== 'left') || plan.rightKeys.some(key => owner(key) !== 'right')) return where
|
|
915
|
+
/**
|
|
916
|
+
* Resolve every reference in a conjunct independently. Subqueries and
|
|
917
|
+
* windows have their own scope and must remain above the join.
|
|
918
|
+
*
|
|
919
|
+
* @param {ExprNode} expr
|
|
920
|
+
* @returns {'left' | 'right' | 'literal' | 'unsafe'}
|
|
921
|
+
*/
|
|
922
|
+
function expressionOwner(expr) {
|
|
923
|
+
if (!expr) return 'literal'
|
|
924
|
+
if (expr.type === 'identifier' || expr.type === 'literal') return owner(expr)
|
|
925
|
+
/** @type {ExprNode[]} */
|
|
926
|
+
let children
|
|
927
|
+
if (expr.type === 'binary') children = [expr.left, expr.right]
|
|
928
|
+
else if (expr.type === 'unary') children = [expr.argument]
|
|
929
|
+
else if (expr.type === 'function' && !expr.filter) children = expr.args
|
|
930
|
+
else if (expr.type === 'cast') children = [expr.expr]
|
|
931
|
+
else if (expr.type === 'in valuelist') children = [expr.expr, ...expr.values]
|
|
932
|
+
else if (expr.type === 'subscript') children = [expr.expr, expr.index]
|
|
933
|
+
else if (expr.type === 'case') children = [expr.caseExpr, ...expr.whenClauses.flatMap(w => [w.condition, w.result]), expr.elseResult]
|
|
934
|
+
else return 'unsafe'
|
|
935
|
+
/** @type {'left' | 'right' | 'literal'} */
|
|
936
|
+
let target = 'literal'
|
|
937
|
+
for (const child of children) {
|
|
938
|
+
const side = expressionOwner(child)
|
|
939
|
+
if (side === 'unsafe' || side !== 'literal' && target !== 'literal' && side !== target) return 'unsafe'
|
|
940
|
+
if (side !== 'literal') target = side
|
|
941
|
+
}
|
|
942
|
+
return target
|
|
943
|
+
}
|
|
944
|
+
|
|
876
945
|
/**
|
|
877
946
|
* @param {ExprNode[]} conjuncts
|
|
878
|
-
* @returns {Array<'left' | 'right' | 'residual'>
|
|
947
|
+
* @returns {Array<'left' | 'right' | 'residual'>}
|
|
879
948
|
*/
|
|
880
949
|
function classify(conjuncts) {
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
const left = owner(expr.left)
|
|
886
|
-
const right = owner(expr.right)
|
|
887
|
-
if (left === 'unsafe' || right === 'unsafe') return undefined
|
|
888
|
-
// Only column/literal terms move; cross-table comparisons remain residual.
|
|
889
|
-
targets.push(left !== 'literal' && right === 'literal' ? left : right !== 'literal' && left === 'literal' ? right : 'residual')
|
|
890
|
-
}
|
|
891
|
-
return targets
|
|
950
|
+
return conjuncts.map(function target(expr) {
|
|
951
|
+
const side = expressionOwner(expr)
|
|
952
|
+
return side === 'left' || side === 'right' ? side : 'residual'
|
|
953
|
+
})
|
|
892
954
|
}
|
|
893
955
|
|
|
894
956
|
/**
|
|
@@ -915,7 +977,6 @@ function pushJoinPredicates(plan, where, tables) {
|
|
|
915
977
|
const onConjuncts = []
|
|
916
978
|
if (plan.residual) collectConjuncts(plan.residual, onConjuncts)
|
|
917
979
|
const onTargets = classify(onConjuncts)
|
|
918
|
-
if (!onTargets) return where
|
|
919
980
|
const onResidual = push(onConjuncts, onTargets)
|
|
920
981
|
if (onResidual) plan.residual = onResidual
|
|
921
982
|
else delete plan.residual
|
|
@@ -925,7 +986,7 @@ function pushJoinPredicates(plan, where, tables) {
|
|
|
925
986
|
const whereConjuncts = []
|
|
926
987
|
collectConjuncts(where, whereConjuncts)
|
|
927
988
|
const whereTargets = classify(whereConjuncts)
|
|
928
|
-
return
|
|
989
|
+
return push(whereConjuncts, whereTargets)
|
|
929
990
|
}
|
|
930
991
|
|
|
931
992
|
/**
|
package/src/plan/types.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type QueryPlan =
|
|
|
11
11
|
| LimitNode
|
|
12
12
|
| HashAggregateNode
|
|
13
13
|
| ScalarAggregateNode
|
|
14
|
+
| SemiJoinNode
|
|
14
15
|
| HashJoinNode
|
|
15
16
|
| NestedLoopJoinNode
|
|
16
17
|
| PositionalJoinNode
|
|
@@ -100,6 +101,15 @@ export interface ScalarAggregateNode {
|
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
// Join nodes
|
|
104
|
+
// WHERE IN / NOT IN: retain left rows using membership in the right output.
|
|
105
|
+
export interface SemiJoinNode {
|
|
106
|
+
type: 'SemiJoin'
|
|
107
|
+
left: QueryPlan
|
|
108
|
+
right: QueryPlan
|
|
109
|
+
expr: ExprNode
|
|
110
|
+
not: boolean
|
|
111
|
+
}
|
|
112
|
+
|
|
103
113
|
export interface HashJoinNode {
|
|
104
114
|
type: 'HashJoin'
|
|
105
115
|
joinType: JoinType
|