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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.16.9",
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.4",
45
- "eslint": "10.12.0",
46
- "eslint-plugin-jsdoc": "65.3.4",
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.4"
49
+ "vitest": "5.0.5"
50
50
  }
51
51
  }
@@ -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 total, table-local comparisons into fresh scans of a direct INNER
841
- * equijoin. Scan execution already enforces hints a source cannot apply.
842
- * Validate ON before moving anything; an unsafe ON also blocks WHERE
843
- * pushdown. Safe ON terms can move even when WHERE must remain above the
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' || !expr.prefix) return 'unsafe'
869
- const index = aliases.indexOf(expr.prefix)
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'> | undefined}
947
+ * @returns {Array<'left' | 'right' | 'residual'>}
879
948
  */
880
949
  function classify(conjuncts) {
881
- /** @type {Array<'left' | 'right' | 'residual'>} */
882
- const targets = []
883
- for (const expr of conjuncts) {
884
- if (expr.type !== 'binary' || !['=', '==', '!=', '<>', '<', '<=', '>', '>='].includes(expr.op)) return undefined
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 whereTargets ? push(whereConjuncts, whereTargets) : where
989
+ return push(whereConjuncts, whereTargets)
929
990
  }
930
991
 
931
992
  /**
@@ -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