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 +2 -1
- package/package.json +7 -7
- package/src/ast.d.ts +6 -1
- package/src/execute/execute.js +33 -0
- package/src/execute/semiJoin.js +77 -0
- package/src/index.d.ts +1 -0
- package/src/parse/extractTables.js +4 -0
- package/src/parse/parse.js +11 -0
- package/src/plan/columns.js +6 -1
- package/src/plan/plan.js +88 -24
- package/src/plan/types.d.ts +16 -0
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
|
-
>
|
|
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.
|
|
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.
|
|
44
|
-
"@vitest/coverage-v8": "5.0.
|
|
45
|
-
"eslint": "10.
|
|
46
|
-
"eslint-plugin-jsdoc": "65.
|
|
47
|
-
"globals": "17.
|
|
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.
|
|
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
|
-
|
|
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
|
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'
|
|
@@ -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
|
@@ -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
|
package/src/parse/parse.js
CHANGED
|
@@ -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') {
|
package/src/plan/columns.js
CHANGED
|
@@ -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
|
|
838
|
-
* equijoin. Scan execution
|
|
839
|
-
*
|
|
840
|
-
*
|
|
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'
|
|
866
|
-
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
|
|
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'>
|
|
947
|
+
* @returns {Array<'left' | 'right' | 'residual'>}
|
|
876
948
|
*/
|
|
877
949
|
function classify(conjuncts) {
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
|
989
|
+
return push(whereConjuncts, whereTargets)
|
|
926
990
|
}
|
|
927
991
|
|
|
928
992
|
/**
|
package/src/plan/types.d.ts
CHANGED
|
@@ -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
|