squirreling 0.16.8 → 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/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  Squirreling is a streaming async SQL engine in pure JavaScript. Built for the browser from the ground up: streaming input and output, pluggable data sources, and lazy async cell evaluation. This makes Squirreling ideal for querying data from network sources, APIs, or LLMs where latency and cost matter.
14
14
 
15
- > Part of **[HypStack](https://hypstack.ai/)**, an open-source stack for AI observability.
15
+ > Powers **[HypAware](https://hypaware.ai/?utm_source=github-readme)**, the improvement loop for all your agents.
16
16
 
17
17
  - **Standard SQL**: Full SQL support for querying data (read-only)
18
18
  - **Async UDFs**: User-defined functions can call APIs or models
@@ -146,6 +146,7 @@ Squirreling mostly follows the SQL standard. The following features are supporte
146
146
  - `JOIN` operations: `INNER JOIN`, `LEFT JOIN`, `RIGHT JOIN`, `FULL JOIN`, `CROSS JOIN`, `POSITIONAL JOIN`, `LATERAL VIEW [OUTER] EXPLODE(...)`, with `ON` or `USING (col, ...)` conditions
147
147
  - `GROUP BY` and `HAVING` clauses, including `GROUP BY ALL`
148
148
  - Set operations: `UNION`, `UNION ALL`, `INTERSECT`, `INTERSECT ALL`, `EXCEPT`, `EXCEPT ALL`
149
+ - `SHOW TABLES` to list the available tables
149
150
  - Expressions: `CASE`, `CAST`, `TRY_CAST`, `BETWEEN`, `IN`, `LIKE`, `IS NULL`, `IS NOT NULL`, string concatenation `||`
150
151
  - Subscript access: zero-based array indexing `col[0]`, struct field access `col['field']`, and chains like `col[0].field`
151
152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.16.8",
3
+ "version": "0.16.10",
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.6.3",
44
- "@vitest/coverage-v8": "5.0.2",
45
- "eslint": "10.11.0",
46
- "eslint-plugin-jsdoc": "65.0.0",
47
- "globals": "17.12.0",
43
+ "@types/node": "26.6.5",
44
+ "@vitest/coverage-v8": "5.0.5",
45
+ "eslint": "10.13.0",
46
+ "eslint-plugin-jsdoc": "65.4.0",
47
+ "globals": "17.13.0",
48
48
  "typescript": "7.0.2",
49
- "vitest": "5.0.2"
49
+ "vitest": "5.0.5"
50
50
  }
51
51
  }
package/src/ast.d.ts CHANGED
@@ -41,7 +41,12 @@ export interface WithStatement extends AstBase {
41
41
  query: Statement
42
42
  }
43
43
 
44
- export type Statement = SelectStatement | SetOperationStatement | WithStatement
44
+ // SHOW TABLES statement: lists the names of the available tables
45
+ export interface ShowTablesStatement extends AstBase {
46
+ type: 'show tables'
47
+ }
48
+
49
+ export type Statement = SelectStatement | SetOperationStatement | WithStatement | ShowTablesStatement
45
50
 
46
51
  export interface CTEDefinition extends AstBase {
47
52
  name: string
@@ -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'
@@ -37,6 +38,9 @@ const YIELD_INTERVAL = 4000
37
38
  export function executeSql({ tables, query, functions, signal }) {
38
39
  const parsed = typeof query === 'string' ? parseSql({ query, functions }) : query
39
40
 
41
+ // Listing names does not require table contents or inferred schemas.
42
+ if (parsed.type === 'show tables') return executeShowTables({ tables, signal })
43
+
40
44
  // Normalize tables: convert arrays to AsyncDataSource
41
45
  /** @type {Record<string, AsyncDataSource>} */
42
46
  const normalizedTables = {}
@@ -97,12 +101,16 @@ export function executePlan({ plan, context }) {
97
101
  return executeScan(plan, context)
98
102
  } else if (plan.type === 'SingleRow') {
99
103
  return executeSingleRow()
104
+ } else if (plan.type === 'ShowTables') {
105
+ return executeShowTables(context)
100
106
  } else if (plan.type === 'Count') {
101
107
  return executeCount(plan, context)
102
108
  } else if (plan.type === 'Filter') {
103
109
  return executeFilter(plan, context)
104
110
  } else if (plan.type === 'Project') {
105
111
  return executeProject(plan, context)
112
+ } else if (plan.type === 'SemiJoin') {
113
+ return executeSemiJoin(plan, context)
106
114
  } else if (plan.type === 'HashJoin') {
107
115
  return executeHashJoin(plan, context)
108
116
  } else if (plan.type === 'NestedLoopJoin') {
@@ -148,6 +156,31 @@ function executeSingleRow() {
148
156
  }
149
157
  }
150
158
 
159
+ /**
160
+ * Executes SHOW TABLES: yields one row per available table name, sorted.
161
+ *
162
+ * @param {Pick<ExecuteSqlOptions, 'tables' | 'signal'>} context
163
+ * @returns {QueryResults}
164
+ */
165
+ function executeShowTables(context) {
166
+ const columns = ['name']
167
+ const names = Object.keys(context.tables).sort()
168
+ return {
169
+ columns,
170
+ numRows: names.length,
171
+ maxRows: names.length,
172
+ async *rows() {
173
+ for (const name of names) {
174
+ context.signal?.throwIfAborted()
175
+ yield {
176
+ columns,
177
+ cells: { name: () => Promise.resolve(name) },
178
+ }
179
+ }
180
+ },
181
+ }
182
+ }
183
+
151
184
  /**
152
185
  * Executes a table-valued function (e.g. UNNEST, JSON_EACH).
153
186
  * Evaluates the argument once against the outer row (for lateral joins) or an
@@ -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/index.d.ts CHANGED
@@ -35,6 +35,7 @@ export type {
35
35
  ScanResults,
36
36
  SelectStatement,
37
37
  SetOperationStatement,
38
+ ShowTablesStatement,
38
39
  SqlType,
39
40
  SqlPrimitive,
40
41
  Statement,
@@ -46,6 +46,10 @@ function walkStatement(stmt, cteScope, refs) {
46
46
  for (const o of stmt.orderBy) walkExpr(o.expr, cteScope, refs)
47
47
  return
48
48
  }
49
+ if (stmt.type === 'show tables') {
50
+ // SHOW TABLES references no source tables
51
+ return
52
+ }
49
53
  // select
50
54
  if (!stmt.from) {
51
55
  // FROM-less SELECT (e.g. `SELECT 1`), no source tables
@@ -47,6 +47,17 @@ export function parseSql({ query, functions }) {
47
47
  */
48
48
  export function parseStatement(state) {
49
49
  const positionStart = state.lastPos
50
+ const showTok = current(state)
51
+ if (showTok.type === 'identifier' && showTok.value.toUpperCase() === 'SHOW') {
52
+ consume(state)
53
+ const tok = current(state)
54
+ if (tok.type !== 'identifier' || tok.value.toUpperCase() !== 'TABLES') {
55
+ throw parseError(state, 'TABLES')
56
+ }
57
+ consume(state)
58
+ match(state, 'semicolon')
59
+ return { type: 'show tables', positionStart: showTok.positionStart, positionEnd: state.lastPos }
60
+ }
50
61
  if (match(state, 'keyword', 'WITH')) {
51
62
  const recursiveTok = current(state)
52
63
  if (recursiveTok.type === 'identifier' && recursiveTok.value.toUpperCase() === 'RECURSIVE') {
@@ -40,7 +40,7 @@ export function fromAlias(from) {
40
40
  */
41
41
  export function statementScope(stmt) {
42
42
  if (stmt.type === 'with') return statementScope(stmt.query)
43
- if (stmt.type === 'compound') return undefined
43
+ if (stmt.type === 'compound' || stmt.type === 'show tables') return undefined
44
44
  const alias = fromAlias(stmt.from)
45
45
  const joins = stmt.joins.map(j => j.alias ?? j.table)
46
46
  return alias === undefined ? joins : [alias, ...joins]
@@ -329,6 +329,7 @@ export function collectColumnsFromExpr(expr, columns, aliases, context) {
329
329
  * @param {ColumnCollectionContext} [context]
330
330
  */
331
331
  function collectCorrelatedColumnsFromStatement(stmt, columns, context) {
332
+ if (stmt.type === 'show tables') return
332
333
  if (stmt.type === 'compound') {
333
334
  collectCorrelatedColumnsFromStatement(stmt.left, columns, context)
334
335
  collectCorrelatedColumnsFromStatement(stmt.right, columns, context)
@@ -386,6 +387,7 @@ function collectCorrelatedColumnsFromStatement(stmt, columns, context) {
386
387
  * @param {ColumnCollectionContext} [context]
387
388
  */
388
389
  function collectFromFunctionArgs(stmt, columns, context) {
390
+ if (stmt.type === 'show tables') return
389
391
  if (stmt.type === 'compound') {
390
392
  collectFromFunctionArgs(stmt.left, columns, context)
391
393
  collectFromFunctionArgs(stmt.right, columns, context)
@@ -414,6 +416,9 @@ function collectFromFunctionArgs(stmt, columns, context) {
414
416
  * @returns {string[]}
415
417
  */
416
418
  export function inferStatementColumns({ stmt, cteColumns, tables }) {
419
+ if (stmt.type === 'show tables') {
420
+ return ['name']
421
+ }
417
422
  if (stmt.type === 'with') {
418
423
  return inferStatementColumns({ stmt: stmt.query, cteColumns, tables })
419
424
  }
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
  /**
@@ -38,6 +38,9 @@ export function planSql({ query, functions, tables, ctePlans, cteColumns }) {
38
38
  * @returns {QueryPlan}
39
39
  */
40
40
  export function planStatement({ stmt, ctePlans, cteColumns, tables, parentColumns, outerScope }) {
41
+ if (stmt.type === 'show tables') {
42
+ return { type: 'ShowTables' }
43
+ }
41
44
  if (stmt.type === 'with') {
42
45
  // Build CTE plans in order (each CTE can reference preceding CTEs)
43
46
  ctePlans ??= new Map()
@@ -227,9 +230,46 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
227
230
  if (hints.columns?.length === 0 && select.from?.type === 'subquery') {
228
231
  hints.columns = undefined
229
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
+ }
230
270
  if (!select.joins.length) {
231
271
  hints.where = select.where
232
- if (!needsBuffering && !select.distinct) {
272
+ if (!needsBuffering && !select.distinct && !semiJoins.length) {
233
273
  hints.limit = select.limit
234
274
  hints.offset = select.offset
235
275
  }
@@ -259,6 +299,8 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
259
299
  plan = { type: 'Filter', condition: where, child: plan }
260
300
  }
261
301
 
302
+ for (const semiJoin of semiJoins) plan = { ...semiJoin, left: plan }
303
+
262
304
  if (useGrouping) {
263
305
  // Aggregation path: GROUP BY or scalar aggregate
264
306
  // HAVING is integrated into aggregate nodes for access to group context
@@ -343,7 +385,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
343
385
  plan = { type: 'Distinct', child: plan }
344
386
  }
345
387
 
346
- if (!(isOwnScan && !needsBuffering && !select.distinct) && (select.limit !== undefined || select.offset)) {
388
+ if (!(isOwnScan && !needsBuffering && !select.distinct && !semiJoins.length) && (select.limit !== undefined || select.offset)) {
347
389
  if (select.limit !== undefined) pushLimitIntoSort(plan, select.limit, select.offset)
348
390
  plan = { type: 'Limit', limit: select.limit, offset: select.offset, child: plan }
349
391
  }
@@ -834,11 +876,10 @@ function collectConjuncts(node, out) {
834
876
  }
835
877
 
836
878
  /**
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.
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.
842
883
  *
843
884
  * @param {QueryPlan} plan
844
885
  * @param {ExprNode | undefined} where
@@ -862,30 +903,54 @@ function pushJoinPredicates(plan, where, tables) {
862
903
  */
863
904
  function owner(expr) {
864
905
  if (expr.type === 'literal') return 'literal'
865
- if (expr.type !== 'identifier' || !expr.prefix) return 'unsafe'
866
- 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
867
909
  if (index < 0 || !columns[index].includes(expr.name)) return 'unsafe'
868
910
  return index === 0 ? 'left' : 'right'
869
911
  }
870
912
 
871
913
  // Avoid changing the evaluation of unresolved or unqualified ON keys.
872
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
+
873
945
  /**
874
946
  * @param {ExprNode[]} conjuncts
875
- * @returns {Array<'left' | 'right' | 'residual'> | undefined}
947
+ * @returns {Array<'left' | 'right' | 'residual'>}
876
948
  */
877
949
  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
950
+ return conjuncts.map(function target(expr) {
951
+ const side = expressionOwner(expr)
952
+ return side === 'left' || side === 'right' ? side : 'residual'
953
+ })
889
954
  }
890
955
 
891
956
  /**
@@ -912,7 +977,6 @@ function pushJoinPredicates(plan, where, tables) {
912
977
  const onConjuncts = []
913
978
  if (plan.residual) collectConjuncts(plan.residual, onConjuncts)
914
979
  const onTargets = classify(onConjuncts)
915
- if (!onTargets) return where
916
980
  const onResidual = push(onConjuncts, onTargets)
917
981
  if (onResidual) plan.residual = onResidual
918
982
  else delete plan.residual
@@ -922,7 +986,7 @@ function pushJoinPredicates(plan, where, tables) {
922
986
  const whereConjuncts = []
923
987
  collectConjuncts(where, whereConjuncts)
924
988
  const whereTargets = classify(whereConjuncts)
925
- return whereTargets ? push(whereConjuncts, whereTargets) : where
989
+ return push(whereConjuncts, whereTargets)
926
990
  }
927
991
 
928
992
  /**
@@ -11,10 +11,12 @@ export type QueryPlan =
11
11
  | LimitNode
12
12
  | HashAggregateNode
13
13
  | ScalarAggregateNode
14
+ | SemiJoinNode
14
15
  | HashJoinNode
15
16
  | NestedLoopJoinNode
16
17
  | PositionalJoinNode
17
18
  | SetOperationNode
19
+ | ShowTablesNode
18
20
  | SubqueryNode
19
21
  | TableFunctionNode
20
22
  | WindowNode
@@ -35,6 +37,11 @@ export interface SingleRowNode {
35
37
  type: 'SingleRow'
36
38
  }
37
39
 
40
+ // SHOW TABLES: yields one row per available table name
41
+ export interface ShowTablesNode {
42
+ type: 'ShowTables'
43
+ }
44
+
38
45
  // Count node for COUNT(*) optimization
39
46
  export interface CountNode {
40
47
  type: 'Count'
@@ -94,6 +101,15 @@ export interface ScalarAggregateNode {
94
101
  }
95
102
 
96
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
+
97
113
  export interface HashJoinNode {
98
114
  type: 'HashJoin'
99
115
  joinType: JoinType