squirreling 0.12.4 → 0.12.6
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 +1 -0
- package/package.json +4 -4
- package/src/ast.d.ts +10 -1
- package/src/execute/aggregates.js +1 -2
- package/src/execute/execute.js +94 -80
- package/src/execute/join.js +76 -0
- package/src/execute/sort.js +29 -7
- package/src/expression/evaluate.js +12 -0
- package/src/parse/joins.js +83 -1
- package/src/parse/parse.js +124 -5
- package/src/parse/primary.js +38 -2
- package/src/parse/tokenize.js +11 -0
- package/src/parse/types.d.ts +1 -0
- package/src/plan/columns.js +105 -10
- package/src/plan/plan.js +180 -6
- package/src/plan/types.d.ts +11 -1
- package/src/types.d.ts +10 -0
- package/src/validation/functions.js +13 -0
- package/src/validation/keywords.js +2 -2
- package/src/validation/tables.js +48 -0
package/README.md
CHANGED
|
@@ -161,6 +161,7 @@ Squirreling mostly follows the SQL standard. The following features are supporte
|
|
|
161
161
|
- Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `DATE_PART`, `DATE_TRUNC`, `EXTRACT`, `INTERVAL`
|
|
162
162
|
- Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_OBJECT`, `JSON_ARRAY_LENGTH`
|
|
163
163
|
- Array: `ARRAY_LENGTH`, `ARRAY_POSITION`, `ARRAY_SORT`, `CARDINALITY`
|
|
164
|
+
- Table functions: `UNNEST`
|
|
164
165
|
- Regex: `REGEXP_SUBSTR`, `REGEXP_EXTRACT`, `REGEXP_REPLACE`, `REGEXP_MATCHES`
|
|
165
166
|
- Spatial: `ST_GeomFromText`, `ST_MakeEnvelope`, `ST_AsText`, `ST_Intersects`, `ST_Contains`, `ST_ContainsProperly`, `ST_Within`, `ST_Overlaps`, `ST_Touches`, `ST_Equals`, `ST_Crosses`, `ST_Covers`, `ST_CoveredBy`, `ST_DWithin`
|
|
166
167
|
- Conditional: `COALESCE`, `NULLIF`, `GREATEST`, `LEAST`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squirreling",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.6",
|
|
4
4
|
"description": "Squirreling Async SQL Engine",
|
|
5
5
|
"author": "Hyperparam",
|
|
6
6
|
"homepage": "https://hyperparam.app",
|
|
@@ -40,10 +40,10 @@
|
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/node": "25.6.0",
|
|
43
|
-
"@vitest/coverage-v8": "4.1.
|
|
43
|
+
"@vitest/coverage-v8": "4.1.5",
|
|
44
44
|
"eslint": "9.39.2",
|
|
45
45
|
"eslint-plugin-jsdoc": "62.9.0",
|
|
46
|
-
"typescript": "6.0.
|
|
47
|
-
"vitest": "4.1.
|
|
46
|
+
"typescript": "6.0.3",
|
|
47
|
+
"vitest": "4.1.5"
|
|
48
48
|
}
|
|
49
49
|
}
|
package/src/ast.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface SelectStatement extends AstBase {
|
|
|
12
12
|
type: 'select'
|
|
13
13
|
distinct: boolean
|
|
14
14
|
columns: SelectColumn[]
|
|
15
|
-
from: FromTable | FromSubquery
|
|
15
|
+
from: FromTable | FromSubquery | FromFunction
|
|
16
16
|
joins: JoinClause[]
|
|
17
17
|
where?: ExprNode
|
|
18
18
|
groupBy: ExprNode[]
|
|
@@ -60,6 +60,14 @@ export interface FromSubquery extends AstBase {
|
|
|
60
60
|
alias?: string
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
export interface FromFunction extends AstBase {
|
|
64
|
+
type: 'function'
|
|
65
|
+
funcName: string
|
|
66
|
+
args: ExprNode[]
|
|
67
|
+
alias?: string
|
|
68
|
+
columnAlias?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
63
71
|
export type ArithmeticOp = '+' | '-' | '*' | '/' | '%'
|
|
64
72
|
|
|
65
73
|
export type BinaryOp = 'AND' | 'OR' | 'LIKE' | ComparisonOp | ArithmeticOp
|
|
@@ -193,6 +201,7 @@ export interface JoinClause extends AstBase {
|
|
|
193
201
|
table: string
|
|
194
202
|
alias?: string
|
|
195
203
|
on?: ExprNode
|
|
204
|
+
fromFunction?: FromFunction
|
|
196
205
|
}
|
|
197
206
|
|
|
198
207
|
// All AST node derive from this base, which includes position info for error reporting and other purposes
|
|
@@ -120,11 +120,10 @@ export function executeHashAggregate(plan, context) {
|
|
|
120
120
|
*/
|
|
121
121
|
export function executeScalarAggregate(plan, context) {
|
|
122
122
|
// Fast path: use scanColumn when available
|
|
123
|
-
const scalarColumns = selectColumnNames(plan.columns, [])
|
|
124
123
|
const fast = tryColumnScanAggregate(plan, context)
|
|
125
124
|
if (fast) {
|
|
126
125
|
return {
|
|
127
|
-
columns:
|
|
126
|
+
columns: selectColumnNames(plan.columns, []),
|
|
128
127
|
numRows: 1,
|
|
129
128
|
maxRows: 1,
|
|
130
129
|
rows: fast,
|
package/src/execute/execute.js
CHANGED
|
@@ -12,7 +12,7 @@ import { addBounds, minBounds, stableRowKey } from './utils.js'
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* @import { AsyncCells, AsyncDataSource, AsyncRow, DerivedColumn, ExecuteContext, ExecuteSqlOptions, ExprNode, IdentifierNode, QueryResults, SelectColumn, SqlPrimitive, Statement } from '../types.js'
|
|
15
|
-
* @import { CountNode, DistinctNode, FilterNode, LimitNode, ProjectNode, QueryPlan, ScanNode, SetOperationNode } from '../plan/types.js'
|
|
15
|
+
* @import { CountNode, DistinctNode, FilterNode, LimitNode, ProjectNode, QueryPlan, ScanNode, SetOperationNode, TableFunctionNode } from '../plan/types.js'
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
/**
|
|
@@ -36,8 +36,16 @@ export function executeSql({ tables, query, functions, signal }) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
const scope = statementScope(parsed)
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
// CTEs are resolved at plan time for FROM/JOIN positions. Subqueries inside
|
|
40
|
+
// expressions are re-planned during execution, so capture the CTE maps here
|
|
41
|
+
// and thread them through the context so those re-plans can still resolve
|
|
42
|
+
// CTE references.
|
|
43
|
+
/** @type {Map<string, QueryPlan>} */
|
|
44
|
+
const ctePlans = new Map()
|
|
45
|
+
/** @type {Map<string, string[]>} */
|
|
46
|
+
const cteColumns = new Map()
|
|
47
|
+
const context = { tables: normalizedTables, functions, signal, scope, ctePlans, cteColumns }
|
|
48
|
+
const plan = planSql({ query: parsed, functions, tables: normalizedTables, ctePlans, cteColumns })
|
|
41
49
|
return executePlan({ plan, context })
|
|
42
50
|
}
|
|
43
51
|
|
|
@@ -51,7 +59,13 @@ export function executeSql({ tables, query, functions, signal }) {
|
|
|
51
59
|
* @returns {QueryResults}
|
|
52
60
|
*/
|
|
53
61
|
export function executeStatement({ query, context, outerScope }) {
|
|
54
|
-
const plan = planStatement({
|
|
62
|
+
const plan = planStatement({
|
|
63
|
+
stmt: query,
|
|
64
|
+
tables: context.tables,
|
|
65
|
+
ctePlans: context.ctePlans,
|
|
66
|
+
cteColumns: context.cteColumns,
|
|
67
|
+
outerScope,
|
|
68
|
+
})
|
|
55
69
|
// Compute this query's scope (FROM alias + JOIN aliases) for nested correlated subqueries
|
|
56
70
|
const scope = statementScope(query)
|
|
57
71
|
return executePlan({ plan, context: scope ? { ...context, scope } : context })
|
|
@@ -104,10 +118,44 @@ export function executePlan({ plan, context }) {
|
|
|
104
118
|
return executeLimit(plan, context)
|
|
105
119
|
} else if (plan.type === 'SetOperation') {
|
|
106
120
|
return executeSetOperation(plan, context)
|
|
121
|
+
} else if (plan.type === 'TableFunction') {
|
|
122
|
+
return executeTableFunction(plan, context)
|
|
107
123
|
}
|
|
108
124
|
return { columns: [], async *rows() {} }
|
|
109
125
|
}
|
|
110
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Executes a table-valued function (e.g. UNNEST).
|
|
129
|
+
* Evaluates the argument once against an empty row and yields one row per
|
|
130
|
+
* element of the resulting array. Null or non-array input yields zero rows.
|
|
131
|
+
*
|
|
132
|
+
* @param {TableFunctionNode} plan
|
|
133
|
+
* @param {ExecuteContext} context
|
|
134
|
+
* @returns {QueryResults}
|
|
135
|
+
*/
|
|
136
|
+
function executeTableFunction(plan, context) {
|
|
137
|
+
if (plan.funcName !== 'UNNEST') {
|
|
138
|
+
throw new Error(`Unsupported table function: ${plan.funcName}`)
|
|
139
|
+
}
|
|
140
|
+
const columns = [plan.columnName]
|
|
141
|
+
return {
|
|
142
|
+
columns,
|
|
143
|
+
async *rows() {
|
|
144
|
+
/** @type {AsyncRow} */
|
|
145
|
+
const row = context.outerRow ?? { columns: [], cells: {} }
|
|
146
|
+
const value = await evaluateExpr({ node: plan.args[0], row, rowIndex: 1, context })
|
|
147
|
+
if (!Array.isArray(value)) return
|
|
148
|
+
for (const element of value) {
|
|
149
|
+
if (context.signal?.aborted) return
|
|
150
|
+
yield {
|
|
151
|
+
columns,
|
|
152
|
+
cells: { [plan.columnName]: () => Promise.resolve(element) },
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
111
159
|
/**
|
|
112
160
|
* Derives output column names from SELECT columns and available child columns.
|
|
113
161
|
*
|
|
@@ -124,7 +172,7 @@ export function selectColumnNames(selectColumns, childColumns) {
|
|
|
124
172
|
for (const key of childColumns) {
|
|
125
173
|
if (prefix && !key.startsWith(prefix)) continue
|
|
126
174
|
const dotIndex = key.indexOf('.')
|
|
127
|
-
const outputKey =
|
|
175
|
+
const outputKey = dotIndex >= 0 ? key.substring(dotIndex + 1) : key
|
|
128
176
|
result.push(outputKey)
|
|
129
177
|
}
|
|
130
178
|
} else {
|
|
@@ -217,33 +265,30 @@ function executeScan(plan, context) {
|
|
|
217
265
|
function executeCount(plan, context) {
|
|
218
266
|
const { tables, signal } = context
|
|
219
267
|
const table = validateTable({ ...plan, tables })
|
|
268
|
+
const columns = plan.columns.map(col => col.alias ?? derivedAlias(col.expr))
|
|
220
269
|
|
|
221
270
|
return {
|
|
222
|
-
columns
|
|
271
|
+
columns,
|
|
223
272
|
numRows: 1,
|
|
224
273
|
maxRows: 1,
|
|
225
274
|
async *rows() {
|
|
226
275
|
// Use source numRows if available
|
|
227
|
-
|
|
228
|
-
if (count === undefined) {
|
|
276
|
+
const countPromise = table.numRows !== undefined ? Promise.resolve(table.numRows) : (async () => {
|
|
229
277
|
// Fall back to counting rows via scan
|
|
230
|
-
count = 0
|
|
278
|
+
let count = 0
|
|
231
279
|
const { rows } = table.scan({ signal })
|
|
232
280
|
// eslint-disable-next-line no-unused-vars
|
|
233
281
|
for await (const _ of rows()) {
|
|
234
282
|
if (signal?.aborted) return
|
|
235
283
|
count++
|
|
236
284
|
}
|
|
237
|
-
|
|
285
|
+
return count
|
|
286
|
+
})()
|
|
238
287
|
|
|
239
|
-
/** @type {string[]} */
|
|
240
|
-
const columns = []
|
|
241
288
|
/** @type {AsyncCells} */
|
|
242
289
|
const cells = {}
|
|
243
|
-
for (const
|
|
244
|
-
|
|
245
|
-
columns.push(alias)
|
|
246
|
-
cells[alias] = () => Promise.resolve(count)
|
|
290
|
+
for (const alias of columns) {
|
|
291
|
+
cells[alias] = () => countPromise
|
|
247
292
|
}
|
|
248
293
|
yield { columns, cells }
|
|
249
294
|
},
|
|
@@ -318,21 +363,19 @@ async function* filterRows(rows, condition, context, limit) {
|
|
|
318
363
|
* @param {AbortSignal} [signal]
|
|
319
364
|
* @yields {AsyncRow}
|
|
320
365
|
*/
|
|
321
|
-
async function* limitRows(rows, limit, offset, signal) {
|
|
322
|
-
|
|
323
|
-
const max = limit ?? Infinity
|
|
324
|
-
if (max <= 0) return
|
|
366
|
+
async function* limitRows(rows, limit = Infinity, offset = 0, signal) {
|
|
367
|
+
if (limit <= 0) return
|
|
325
368
|
let skipped = 0
|
|
326
369
|
let yielded = 0
|
|
327
370
|
for await (const row of rows) {
|
|
328
371
|
if (signal?.aborted) return
|
|
329
|
-
if (skipped <
|
|
372
|
+
if (skipped < offset) {
|
|
330
373
|
skipped++
|
|
331
374
|
continue
|
|
332
375
|
}
|
|
333
376
|
yield row
|
|
334
377
|
yielded++
|
|
335
|
-
if (yielded >=
|
|
378
|
+
if (yielded >= limit) return
|
|
336
379
|
}
|
|
337
380
|
}
|
|
338
381
|
|
|
@@ -361,86 +404,57 @@ function executeFilter(plan, context) {
|
|
|
361
404
|
*/
|
|
362
405
|
function executeProject(plan, context) {
|
|
363
406
|
const child = executePlan({ plan: plan.child, context })
|
|
407
|
+
const columns = selectColumnNames(plan.columns, child.columns)
|
|
364
408
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
/** @type {string[] | undefined} */
|
|
369
|
-
let staticColumns
|
|
370
|
-
/** @type {{ alias: string, sourceName: string }[] | undefined} */
|
|
371
|
-
let identifierMap
|
|
372
|
-
if (!hasStar) {
|
|
373
|
-
const derived = /** @type {DerivedColumn[]} */ (plan.columns)
|
|
374
|
-
staticColumns = derived.map(col => col.alias ?? derivedAlias(col.expr))
|
|
375
|
-
const allIdentifiers = derived.every(col =>
|
|
376
|
-
col.expr.type === 'identifier' && !col.expr.prefix
|
|
377
|
-
)
|
|
378
|
-
if (allIdentifiers) {
|
|
379
|
-
identifierMap = derived.map((col, i) => ({
|
|
380
|
-
alias: staticColumns[i],
|
|
381
|
-
sourceName: /** @type {IdentifierNode} */ (col.expr).name,
|
|
382
|
-
}))
|
|
383
|
-
}
|
|
384
|
-
}
|
|
409
|
+
const resolveable = plan.columns.every(col =>
|
|
410
|
+
col.type === 'star' || col.type === 'derived' && col.expr.type === 'identifier'
|
|
411
|
+
)
|
|
385
412
|
|
|
386
413
|
return {
|
|
387
|
-
columns
|
|
414
|
+
columns,
|
|
388
415
|
numRows: child.numRows,
|
|
389
416
|
maxRows: child.maxRows,
|
|
390
417
|
async *rows() {
|
|
391
418
|
let rowIndex = 0
|
|
392
|
-
let identifierMapValidated = false
|
|
393
419
|
|
|
394
420
|
for await (const row of child.rows()) {
|
|
395
421
|
if (context.signal?.aborted) return
|
|
396
422
|
rowIndex++
|
|
397
|
-
|
|
398
|
-
// Validate identifier fast path on first row (may fail for JOINs with prefixed columns)
|
|
399
|
-
if (identifierMap && !identifierMapValidated) {
|
|
400
|
-
identifierMapValidated = true
|
|
401
|
-
if (!identifierMap.every(m => m.sourceName in row.cells)) {
|
|
402
|
-
identifierMap = undefined
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// Fast path: all columns are simple identifier references
|
|
407
|
-
if (identifierMap) {
|
|
408
|
-
/** @type {AsyncCells} */
|
|
409
|
-
const cells = {}
|
|
410
|
-
const source = row.resolved
|
|
411
|
-
/** @type {Record<string, SqlPrimitive> | undefined} */
|
|
412
|
-
const resolved = source ? {} : undefined
|
|
413
|
-
for (const { alias, sourceName } of identifierMap) {
|
|
414
|
-
cells[alias] = row.cells[sourceName]
|
|
415
|
-
if (resolved && source) resolved[alias] = source[sourceName]
|
|
416
|
-
}
|
|
417
|
-
yield resolved
|
|
418
|
-
? { columns: staticColumns, cells, resolved }
|
|
419
|
-
: { columns: staticColumns, cells }
|
|
420
|
-
continue
|
|
421
|
-
}
|
|
422
|
-
|
|
423
423
|
const currentRowIndex = rowIndex
|
|
424
424
|
|
|
425
|
-
/** @type {string[]} */
|
|
426
|
-
const columns = staticColumns ?? []
|
|
427
425
|
/** @type {AsyncCells} */
|
|
428
426
|
const cells = {}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
427
|
+
// Only safe to propagate resolved when every output column comes from
|
|
428
|
+
// the star branch — derived expressions evaluate lazily and can't be
|
|
429
|
+
// pre-materialized here, and a partial resolved would make
|
|
430
|
+
// collect()/downstream identifier fast paths read undefined.
|
|
431
|
+
const source = resolveable ? row.resolved : undefined
|
|
432
|
+
/** @type {Record<string, SqlPrimitive> | undefined} */
|
|
433
|
+
const resolved = source ? {} : undefined
|
|
434
|
+
|
|
435
|
+
let colIdx = 0
|
|
436
|
+
for (const col of plan.columns) {
|
|
432
437
|
if (col.type === 'star') {
|
|
433
438
|
const prefix = col.table ? `${col.table}.` : undefined
|
|
434
439
|
for (const key of row.columns) {
|
|
435
440
|
if (prefix && !key.startsWith(prefix)) continue
|
|
436
441
|
const dotIndex = key.indexOf('.')
|
|
437
|
-
const outputKey =
|
|
438
|
-
columns.push(outputKey)
|
|
442
|
+
const outputKey = dotIndex >= 0 ? key.substring(dotIndex + 1) : key
|
|
439
443
|
cells[outputKey] = row.cells[key]
|
|
444
|
+
if (resolved && source) resolved[outputKey] = source[key]
|
|
445
|
+
colIdx++
|
|
440
446
|
}
|
|
447
|
+
} else if (col.expr.type === 'identifier') {
|
|
448
|
+
// Common case: simple identifier. Avoid evaluateExpr overhead by
|
|
449
|
+
// directly mapping to the child's cell, relying on the planner to
|
|
450
|
+
// have normalized the identifier to match the child's column layout.
|
|
451
|
+
const id = col.expr
|
|
452
|
+
const sourceName = id.prefix ? `${id.prefix}.${id.name}` : id.name
|
|
453
|
+
cells[columns[colIdx]] = row.cells[sourceName]
|
|
454
|
+
if (resolved && source) resolved[columns[colIdx]] = source[sourceName]
|
|
455
|
+
colIdx++
|
|
441
456
|
} else {
|
|
442
|
-
const alias =
|
|
443
|
-
if (!staticColumns) columns.push(alias)
|
|
457
|
+
const alias = columns[colIdx++]
|
|
444
458
|
cells[alias] = () => evaluateExpr({
|
|
445
459
|
node: col.expr,
|
|
446
460
|
row,
|
|
@@ -450,7 +464,7 @@ function executeProject(plan, context) {
|
|
|
450
464
|
}
|
|
451
465
|
}
|
|
452
466
|
|
|
453
|
-
yield { columns, cells }
|
|
467
|
+
yield { columns, cells, resolved }
|
|
454
468
|
}
|
|
455
469
|
},
|
|
456
470
|
}
|
package/src/execute/join.js
CHANGED
|
@@ -15,6 +15,9 @@ import { executePlan } from './execute.js'
|
|
|
15
15
|
* @returns {QueryResults}
|
|
16
16
|
*/
|
|
17
17
|
export function executeNestedLoopJoin(plan, context) {
|
|
18
|
+
if (plan.lateral) {
|
|
19
|
+
return executeLateralJoin(plan, context)
|
|
20
|
+
}
|
|
18
21
|
const left = executePlan({ plan: plan.left, context })
|
|
19
22
|
const right = executePlan({ plan: plan.right, context })
|
|
20
23
|
return {
|
|
@@ -81,6 +84,57 @@ export function executeNestedLoopJoin(plan, context) {
|
|
|
81
84
|
}
|
|
82
85
|
}
|
|
83
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Executes a LATERAL nested loop join — the right side is re-executed per
|
|
89
|
+
* left row with the left row available as `context.outerRow`.
|
|
90
|
+
*
|
|
91
|
+
* @param {NestedLoopJoinNode} plan
|
|
92
|
+
* @param {ExecuteContext} context
|
|
93
|
+
* @returns {QueryResults}
|
|
94
|
+
*/
|
|
95
|
+
function executeLateralJoin(plan, context) {
|
|
96
|
+
const left = executePlan({ plan: plan.left, context })
|
|
97
|
+
// Right columns are known statically for table functions (the common case).
|
|
98
|
+
const rightCols = plan.right.type === 'TableFunction' ? [plan.right.columnName] : []
|
|
99
|
+
return {
|
|
100
|
+
columns: mergeColumnNames(left.columns, rightCols, plan.leftAlias, plan.rightAlias),
|
|
101
|
+
async *rows() {
|
|
102
|
+
const leftTable = plan.leftAlias
|
|
103
|
+
const rightTable = plan.rightAlias
|
|
104
|
+
|
|
105
|
+
for await (const leftRow of left.rows()) {
|
|
106
|
+
if (context.signal?.aborted) return
|
|
107
|
+
|
|
108
|
+
// When nested inside a correlated subquery, preserve the enclosing
|
|
109
|
+
// outer row so UNNEST args can reference its columns (e.g. o.arr).
|
|
110
|
+
const nestedOuter = context.outerRow
|
|
111
|
+
? mergeOuterRows(context.outerRow, leftRow, leftTable)
|
|
112
|
+
: leftRow
|
|
113
|
+
const subContext = { ...context, outerRow: nestedOuter }
|
|
114
|
+
const right = executePlan({ plan: plan.right, context: subContext })
|
|
115
|
+
|
|
116
|
+
let hasMatch = false
|
|
117
|
+
for await (const rightRow of right.rows()) {
|
|
118
|
+
if (context.signal?.aborted) return
|
|
119
|
+
const merged = mergeRows(leftRow, rightRow, leftTable, rightTable)
|
|
120
|
+
const matches = plan.condition === undefined
|
|
121
|
+
? true
|
|
122
|
+
: await evaluateExpr({ node: plan.condition, row: merged, context })
|
|
123
|
+
if (matches) {
|
|
124
|
+
hasMatch = true
|
|
125
|
+
yield merged
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!hasMatch && plan.joinType === 'LEFT') {
|
|
130
|
+
const nullRight = createNullRow(rightCols)
|
|
131
|
+
yield mergeRows(leftRow, nullRight, leftTable, rightTable)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
84
138
|
/**
|
|
85
139
|
* Executes a positional join operation
|
|
86
140
|
*
|
|
@@ -221,6 +275,28 @@ export function executeHashJoin(plan, context) {
|
|
|
221
275
|
}
|
|
222
276
|
}
|
|
223
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Merges an enclosing correlated outer row with a lateral join's left row.
|
|
280
|
+
* Outer cells are kept as-is; left cells are added under a qualified alias
|
|
281
|
+
* so qualified refs on either side resolve unambiguously.
|
|
282
|
+
*
|
|
283
|
+
* @param {AsyncRow} outerRow
|
|
284
|
+
* @param {AsyncRow} leftRow
|
|
285
|
+
* @param {string} leftTable
|
|
286
|
+
* @returns {AsyncRow}
|
|
287
|
+
*/
|
|
288
|
+
function mergeOuterRows(outerRow, leftRow, leftTable) {
|
|
289
|
+
const columns = [...outerRow.columns]
|
|
290
|
+
/** @type {AsyncCells} */
|
|
291
|
+
const cells = { ...outerRow.cells }
|
|
292
|
+
for (const [key, cell] of Object.entries(leftRow.cells)) {
|
|
293
|
+
const alias = key.includes('.') ? key : `${leftTable}.${key}`
|
|
294
|
+
if (!(alias in cells)) columns.push(alias)
|
|
295
|
+
cells[alias] = cell
|
|
296
|
+
}
|
|
297
|
+
return { columns, cells }
|
|
298
|
+
}
|
|
299
|
+
|
|
224
300
|
/**
|
|
225
301
|
* Creates a NULL-filled row with the given column names
|
|
226
302
|
*
|
package/src/execute/sort.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { derivedAlias } from '../expression/alias.js'
|
|
1
2
|
import { evaluateExpr } from '../expression/evaluate.js'
|
|
2
3
|
import { executePlan } from './execute.js'
|
|
3
4
|
import { compareForTerm } from './utils.js'
|
|
@@ -7,6 +8,8 @@ import { compareForTerm } from './utils.js'
|
|
|
7
8
|
* @import { SortNode } from '../plan/types.js'
|
|
8
9
|
*/
|
|
9
10
|
|
|
11
|
+
const MAX_CHUNK = 256
|
|
12
|
+
|
|
10
13
|
/**
|
|
11
14
|
* Executes a sort operation (ORDER BY)
|
|
12
15
|
*
|
|
@@ -49,15 +52,34 @@ export function executeSort(plan, context) {
|
|
|
49
52
|
continue
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
// Evaluate this column for all rows in the group
|
|
55
|
+
// Evaluate this column for all rows in the group, in parallel
|
|
56
|
+
// chunks that double up to MAX_CHUNK so a slow UDF doesn't serialize.
|
|
57
|
+
// Cache each value back into the row so downstream projection can
|
|
58
|
+
// reuse it instead of re-invoking the expression.
|
|
59
|
+
const alias = derivedAlias(term.expr)
|
|
60
|
+
/** @type {number[]} */
|
|
61
|
+
const missing = []
|
|
53
62
|
for (const idx of group) {
|
|
54
|
-
if (evaluatedValues[idx][orderByIdx] === undefined)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
if (evaluatedValues[idx][orderByIdx] === undefined) missing.push(idx)
|
|
64
|
+
}
|
|
65
|
+
let chunkSize = 1
|
|
66
|
+
let start = 0
|
|
67
|
+
while (start < missing.length) {
|
|
68
|
+
if (context.signal?.aborted) return
|
|
69
|
+
const chunk = missing.slice(start, start + chunkSize)
|
|
70
|
+
const values = await Promise.all(chunk.map(idx =>
|
|
71
|
+
evaluateExpr({ node: term.expr, row: rows[idx], context })
|
|
72
|
+
))
|
|
73
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
74
|
+
const idx = chunk[i]
|
|
75
|
+
const value = values[i]
|
|
76
|
+
evaluatedValues[idx][orderByIdx] = value
|
|
77
|
+
if (!(alias in rows[idx].cells)) {
|
|
78
|
+
rows[idx].cells[alias] = () => Promise.resolve(value)
|
|
79
|
+
}
|
|
60
80
|
}
|
|
81
|
+
start += chunk.length
|
|
82
|
+
chunkSize = Math.min(chunkSize * 2, MAX_CHUNK)
|
|
61
83
|
}
|
|
62
84
|
|
|
63
85
|
// Sort the group by this column
|
|
@@ -119,6 +119,18 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
119
119
|
if (node.type === 'function') {
|
|
120
120
|
const funcName = node.funcName.toUpperCase()
|
|
121
121
|
|
|
122
|
+
// Reuse a previously cached evaluation of this expression, written back
|
|
123
|
+
// as a synthetic cell (e.g. by executeSort). Cached cells are not added to
|
|
124
|
+
// row.columns, so checking that the alias is NOT a real column guards
|
|
125
|
+
// against false positives where a table column happens to share a name
|
|
126
|
+
// with the expression's derived alias.
|
|
127
|
+
if (!rows) {
|
|
128
|
+
const alias = derivedAlias(node)
|
|
129
|
+
if (alias in row.cells && !row.columns.includes(alias)) {
|
|
130
|
+
return row.cells[alias]()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
122
134
|
// Handle aggregate functions
|
|
123
135
|
if (isAggregateFunc(funcName)) {
|
|
124
136
|
if (!rows) {
|
package/src/parse/joins.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { expectNoAggregate } from '../validation/aggregates.js'
|
|
2
|
+
import { ParseError } from '../validation/parseErrors.js'
|
|
2
3
|
import { parseExpression } from './expression.js'
|
|
3
|
-
import { parseTableAlias } from './parse.js'
|
|
4
|
+
import { isTableFunctionStart, parseFromFunction, parseTableAlias } from './parse.js'
|
|
4
5
|
import { current, expect, match } from './state.js'
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -18,6 +19,27 @@ export function parseJoins(state) {
|
|
|
18
19
|
while (true) {
|
|
19
20
|
const tok = current(state)
|
|
20
21
|
|
|
22
|
+
// Comma-join: implicit CROSS JOIN LATERAL, currently only for table functions.
|
|
23
|
+
if (match(state, 'comma')) {
|
|
24
|
+
if (!isTableFunctionStart(state)) {
|
|
25
|
+
throw new ParseError({
|
|
26
|
+
message: 'Comma-separated FROM is only supported with table functions like UNNEST; use explicit JOIN ... ON ... for regular tables',
|
|
27
|
+
positionStart: tok.positionStart,
|
|
28
|
+
positionEnd: state.lastPos,
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
const fromFunction = parseFromFunction(state)
|
|
32
|
+
joins.push({
|
|
33
|
+
joinType: 'CROSS',
|
|
34
|
+
table: fromFunction.funcName,
|
|
35
|
+
alias: fromFunction.alias,
|
|
36
|
+
fromFunction,
|
|
37
|
+
positionStart: tok.positionStart,
|
|
38
|
+
positionEnd: state.lastPos,
|
|
39
|
+
})
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
|
|
21
43
|
// Check for join keywords
|
|
22
44
|
/** @type {JoinType} */
|
|
23
45
|
let joinType = 'INNER'
|
|
@@ -35,6 +57,8 @@ export function parseJoins(state) {
|
|
|
35
57
|
joinType = 'FULL'
|
|
36
58
|
} else if (match(state, 'keyword', 'POSITIONAL')) {
|
|
37
59
|
joinType = 'POSITIONAL'
|
|
60
|
+
} else if (match(state, 'keyword', 'CROSS')) {
|
|
61
|
+
joinType = 'CROSS'
|
|
38
62
|
} else if (!match(state, 'keyword', 'JOIN')) {
|
|
39
63
|
// Not a join keyword, stop parsing joins
|
|
40
64
|
break
|
|
@@ -45,6 +69,64 @@ export function parseJoins(state) {
|
|
|
45
69
|
expect(state, 'keyword', 'JOIN')
|
|
46
70
|
}
|
|
47
71
|
|
|
72
|
+
// Optional LATERAL keyword; table functions are implicitly LATERAL.
|
|
73
|
+
const lateralTok = current(state)
|
|
74
|
+
const hasLateral = match(state, 'keyword', 'LATERAL')
|
|
75
|
+
|
|
76
|
+
// Table function on the right side (e.g. JOIN UNNEST(t.arr) AS u(x))
|
|
77
|
+
if (isTableFunctionStart(state)) {
|
|
78
|
+
if (joinType === 'POSITIONAL') {
|
|
79
|
+
throw new ParseError({
|
|
80
|
+
message: 'POSITIONAL JOIN does not support table functions',
|
|
81
|
+
positionStart: tok.positionStart,
|
|
82
|
+
positionEnd: state.lastPos,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
if (joinType === 'RIGHT' || joinType === 'FULL') {
|
|
86
|
+
throw new ParseError({
|
|
87
|
+
message: `${joinType} JOIN not supported with table functions — right side depends on left row`,
|
|
88
|
+
positionStart: tok.positionStart,
|
|
89
|
+
positionEnd: state.lastPos,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
const fromFunction = parseFromFunction(state)
|
|
93
|
+
|
|
94
|
+
/** @type {ExprNode | undefined} */
|
|
95
|
+
let condition
|
|
96
|
+
if (joinType !== 'CROSS') {
|
|
97
|
+
expect(state, 'keyword', 'ON')
|
|
98
|
+
condition = parseExpression(state)
|
|
99
|
+
expectNoAggregate(condition, 'JOIN ON')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
joins.push({
|
|
103
|
+
joinType,
|
|
104
|
+
table: fromFunction.funcName,
|
|
105
|
+
alias: fromFunction.alias,
|
|
106
|
+
on: condition,
|
|
107
|
+
fromFunction,
|
|
108
|
+
positionStart: tok.positionStart,
|
|
109
|
+
positionEnd: state.lastPos,
|
|
110
|
+
})
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (hasLateral) {
|
|
115
|
+
throw new ParseError({
|
|
116
|
+
message: 'LATERAL is only supported with table functions',
|
|
117
|
+
positionStart: lateralTok.positionStart,
|
|
118
|
+
positionEnd: lateralTok.positionEnd,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (joinType === 'CROSS') {
|
|
123
|
+
throw new ParseError({
|
|
124
|
+
message: 'CROSS JOIN is currently supported only with table functions like UNNEST',
|
|
125
|
+
positionStart: tok.positionStart,
|
|
126
|
+
positionEnd: state.lastPos,
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
48
130
|
// Parse table name and optional alias
|
|
49
131
|
const tableTok = expect(state, 'identifier')
|
|
50
132
|
const tableAlias = parseTableAlias(state)
|