squirreling 0.13.0 → 0.15.0
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 +3 -2
- package/package.json +6 -6
- package/src/ast.d.ts +9 -2
- package/src/execute/aggregates.js +109 -54
- package/src/execute/execute.js +41 -17
- package/src/execute/scanColumn.js +17 -0
- package/src/execute/streamingAggregate.js +9 -0
- package/src/expression/alias.js +7 -0
- package/src/expression/binary.js +6 -0
- package/src/expression/date.js +1 -1
- package/src/expression/evaluate.js +26 -1
- package/src/expression/strings.js +38 -0
- package/src/parse/expression.js +36 -9
- package/src/parse/extractTables.js +4 -0
- package/src/parse/functions.js +12 -1
- package/src/parse/parse.js +8 -0
- package/src/parse/primary.js +87 -11
- package/src/parse/tokenize.js +12 -0
- package/src/plan/columns.js +3 -0
- package/src/plan/plan.js +21 -0
- package/src/types.d.ts +12 -1
- package/src/validation/aggregates.js +3 -0
- package/src/validation/functions.js +6 -3
- package/src/validation/tables.js +6 -0
package/README.md
CHANGED
|
@@ -146,7 +146,8 @@ 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
|
|
148
148
|
- Set operations: `UNION`, `UNION ALL`, `INTERSECT`, `INTERSECT ALL`, `EXCEPT`, `EXCEPT ALL`
|
|
149
|
-
- Expressions: `CASE`, `CAST`, `BETWEEN`, `IN`, `LIKE`, `IS NULL`, `IS NOT NULL
|
|
149
|
+
- Expressions: `CASE`, `CAST`, `BETWEEN`, `IN`, `LIKE`, `IS NULL`, `IS NOT NULL`, string concatenation `||`
|
|
150
|
+
- Subscript access: zero-based array indexing `col[0]`, struct field access `col['field']`, and chains like `col[0].field`
|
|
150
151
|
|
|
151
152
|
### Quoting
|
|
152
153
|
|
|
@@ -158,7 +159,7 @@ Squirreling mostly follows the SQL standard. The following features are supporte
|
|
|
158
159
|
|
|
159
160
|
- Aggregate: `COUNT`, `COUNTIF`, `SUM`, `AVG`, `MIN`, `MAX`, `MEDIAN`, `PERCENTILE_CONT`, `APPROX_QUANTILE`, `STDDEV_POP`, `STDDEV_SAMP`, `ARRAY_AGG`, `JSON_ARRAYAGG`, `STRING_AGG`
|
|
160
161
|
- Window: `ROW_NUMBER`, `LAG`, `LEAD`
|
|
161
|
-
- String: `CONCAT`, `SUBSTRING`, `REPLACE`, `LENGTH`, `UPPER`, `LOWER`, `TRIM`, `LEFT`, `RIGHT`, `INSTR`, `POSITION`, `STRPOS`
|
|
162
|
+
- String: `CONCAT`, `SUBSTRING`, `REPLACE`, `LENGTH`, `OCTET_LENGTH`, `UPPER`, `LOWER`, `TRIM`, `LEFT`, `RIGHT`, `INSTR`, `POSITION`, `STRPOS`, `SPLIT_PART`, `STRING_SPLIT`
|
|
162
163
|
- Math: `ABS`, `SIGN`, `CEIL`, `FLOOR`, `ROUND`, `MOD`, `RAND`, `RANDOM`, `LN`, `LOG10`, `EXP`, `POWER`, `SQRT`
|
|
163
164
|
- Trig: `SIN`, `COS`, `TAN`, `COT`, `ASIN`, `ACOS`, `ATAN`, `ATAN2`, `DEGREES`, `RADIANS`, `PI`
|
|
164
165
|
- Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `DATE_DIFF`, `DATEDIFF`, `DATE_PART`, `DATE_TRUNC`, `EPOCH`, `EXTRACT`, `INTERVAL`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squirreling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Squirreling Async SQL Engine",
|
|
5
5
|
"author": "Hyperparam",
|
|
6
6
|
"homepage": "https://hyperparam.app",
|
|
@@ -39,11 +39,11 @@
|
|
|
39
39
|
"test": "vitest run"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
|
-
"@types/node": "26.1.
|
|
43
|
-
"@vitest/coverage-v8": "4.1.
|
|
42
|
+
"@types/node": "26.1.1",
|
|
43
|
+
"@vitest/coverage-v8": "4.1.10",
|
|
44
44
|
"eslint": "9.39.4",
|
|
45
|
-
"eslint-plugin-jsdoc": "63.0.
|
|
46
|
-
"typescript": "
|
|
47
|
-
"vitest": "4.1.
|
|
45
|
+
"eslint-plugin-jsdoc": "63.0.13",
|
|
46
|
+
"typescript": "7.0.2",
|
|
47
|
+
"vitest": "4.1.10"
|
|
48
48
|
}
|
|
49
49
|
}
|
package/src/ast.d.ts
CHANGED
|
@@ -70,7 +70,7 @@ export interface FromFunction extends AstBase {
|
|
|
70
70
|
|
|
71
71
|
export type ArithmeticOp = '+' | '-' | '*' | '/' | '%'
|
|
72
72
|
|
|
73
|
-
export type BinaryOp = 'AND' | 'OR' | 'LIKE' | ComparisonOp | ArithmeticOp
|
|
73
|
+
export type BinaryOp = 'AND' | 'OR' | 'LIKE' | '||' | ComparisonOp | ArithmeticOp
|
|
74
74
|
|
|
75
75
|
export type ComparisonOp = '=' | '==' | '!=' | '<>' | '<' | '>' | '<=' | '>='
|
|
76
76
|
|
|
@@ -114,7 +114,7 @@ export interface WindowFunctionNode extends AstBase {
|
|
|
114
114
|
orderBy: OrderByItem[]
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
export type CastType = 'TEXT' | 'STRING' | 'VARCHAR' | 'INTEGER' | 'INT' | 'BIGINT' | 'FLOAT' | 'REAL' | 'DOUBLE' | 'BOOLEAN' | 'BOOL'
|
|
117
|
+
export type CastType = 'TEXT' | 'STRING' | 'VARCHAR' | 'INTEGER' | 'INT' | 'BIGINT' | 'FLOAT' | 'REAL' | 'DOUBLE' | 'BOOLEAN' | 'BOOL' | 'TIMESTAMP'
|
|
118
118
|
|
|
119
119
|
export interface CastNode extends AstBase {
|
|
120
120
|
type: 'cast'
|
|
@@ -168,6 +168,12 @@ export interface StarNode extends AstBase {
|
|
|
168
168
|
type: 'star'
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
export interface SubscriptNode extends AstBase {
|
|
172
|
+
type: 'subscript'
|
|
173
|
+
expr: ExprNode
|
|
174
|
+
index: ExprNode
|
|
175
|
+
}
|
|
176
|
+
|
|
171
177
|
export type ExprNode =
|
|
172
178
|
| LiteralNode
|
|
173
179
|
| IdentifierNode
|
|
@@ -183,6 +189,7 @@ export type ExprNode =
|
|
|
183
189
|
| SubqueryNode
|
|
184
190
|
| IntervalNode
|
|
185
191
|
| StarNode
|
|
192
|
+
| SubscriptNode
|
|
186
193
|
|
|
187
194
|
export interface StarColumn extends AstBase {
|
|
188
195
|
type: 'star'
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { derivedAlias } from '../expression/alias.js'
|
|
2
2
|
import { evaluateExpr } from '../expression/evaluate.js'
|
|
3
3
|
import { finalizeAccumulator, newAccumulator, updateAccumulator } from './accumulator.js'
|
|
4
|
-
import { executePlan, selectColumnNames } from './execute.js'
|
|
4
|
+
import { executePlan, executeScan, selectColumnNames } from './execute.js'
|
|
5
|
+
import { normalizeScanColumnResult } from './scanColumn.js'
|
|
5
6
|
import { sortEntriesByTerms } from './sort.js'
|
|
6
7
|
import { planStreamingAggregates, streamingHashAggregateRows, streamingScalarAggregateRows } from './streamingAggregate.js'
|
|
7
8
|
import { keyify } from './utils.js'
|
|
@@ -195,17 +196,21 @@ export function executeHashAggregate(plan, context) {
|
|
|
195
196
|
*/
|
|
196
197
|
export function executeScalarAggregate(plan, context) {
|
|
197
198
|
// Fast path: use scanColumn when available
|
|
198
|
-
const
|
|
199
|
-
if (
|
|
199
|
+
const columnScan = tryColumnScanAggregate(plan, context)
|
|
200
|
+
if (columnScan?.rows) {
|
|
200
201
|
return {
|
|
201
202
|
columns: selectColumnNames(plan.columns, []),
|
|
202
203
|
numRows: 1,
|
|
203
204
|
maxRows: 1,
|
|
204
|
-
rows:
|
|
205
|
+
rows: columnScan.rows,
|
|
205
206
|
}
|
|
206
207
|
}
|
|
207
208
|
|
|
208
|
-
|
|
209
|
+
// A declined pushdown still returned a usable one-column scan. Transfer it
|
|
210
|
+
// to the ordinary scan path instead of calling the source a second time.
|
|
211
|
+
const child = columnScan?.fallback
|
|
212
|
+
? executeScan(columnScan.fallback.plan, context, columnScan.fallback.result)
|
|
213
|
+
: executePlan({ plan: plan.child, context })
|
|
209
214
|
const streaming = planStreamingAggregates(plan, child.columns)
|
|
210
215
|
if (streaming) {
|
|
211
216
|
return {
|
|
@@ -263,6 +268,7 @@ export function executeScalarAggregate(plan, context) {
|
|
|
263
268
|
* column: string,
|
|
264
269
|
* alias: string,
|
|
265
270
|
* distinct?: boolean,
|
|
271
|
+
* star?: boolean,
|
|
266
272
|
* }} ColumnAggSpec
|
|
267
273
|
*/
|
|
268
274
|
|
|
@@ -272,7 +278,13 @@ export function executeScalarAggregate(plan, context) {
|
|
|
272
278
|
*
|
|
273
279
|
* @param {ScalarAggregateNode} plan
|
|
274
280
|
* @param {ExecuteContext} context
|
|
275
|
-
* @returns {
|
|
281
|
+
* @returns {{
|
|
282
|
+
* rows?: () => AsyncGenerator<AsyncRow>,
|
|
283
|
+
* fallback?: {
|
|
284
|
+
* plan: import('../plan/types.js').ScanNode,
|
|
285
|
+
* result: import('../types.js').ScanColumnResults,
|
|
286
|
+
* },
|
|
287
|
+
* } | undefined}
|
|
276
288
|
*/
|
|
277
289
|
function tryColumnScanAggregate(plan, { tables, signal }) {
|
|
278
290
|
// No HAVING support in fast path
|
|
@@ -281,61 +293,96 @@ function tryColumnScanAggregate(plan, { tables, signal }) {
|
|
|
281
293
|
if (plan.child.type !== 'Scan') return
|
|
282
294
|
const scanNode = plan.child
|
|
283
295
|
const { limit, offset, where } = scanNode.hints
|
|
284
|
-
// scanColumn doesn't support filtering
|
|
285
|
-
if (where) return
|
|
286
296
|
|
|
287
297
|
const table = tables[scanNode.table]
|
|
288
298
|
if (!table?.scanColumn) return
|
|
289
299
|
|
|
300
|
+
// COUNT(*) needs a physical column whose filtered chunk lengths can be
|
|
301
|
+
// counted. Prefer a predicate/projection column, then any table column.
|
|
302
|
+
const starColumn = scanNode.hints.columns?.[0] ?? table.columns[0]
|
|
303
|
+
if (!starColumn) return
|
|
304
|
+
|
|
290
305
|
// All columns must be simple aggregates on plain identifiers
|
|
291
306
|
/** @type {ColumnAggSpec[]} */
|
|
292
307
|
const specs = []
|
|
293
308
|
for (const col of plan.columns) {
|
|
294
309
|
if (col.type !== 'derived') return
|
|
295
|
-
const spec = extractColumnAggSpec(col)
|
|
310
|
+
const spec = extractColumnAggSpec(col, starColumn)
|
|
296
311
|
if (!spec) return
|
|
297
312
|
specs.push(spec)
|
|
298
313
|
}
|
|
299
314
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
315
|
+
const physicalColumns = new Set(specs.map(spec => spec.column))
|
|
316
|
+
const hasPushdown = where || limit !== undefined || offset !== undefined
|
|
317
|
+
if (hasPushdown) {
|
|
318
|
+
// If negotiation fails, the returned column stream can only replace the
|
|
319
|
+
// ordinary scan when that scan needs the same single column. Otherwise a
|
|
320
|
+
// probe could allocate work that no correct fallback is able to consume.
|
|
321
|
+
const scanColumns = scanNode.hints.columns
|
|
322
|
+
if (physicalColumns.size !== 1 || scanColumns?.length !== 1 ||
|
|
323
|
+
!physicalColumns.has(scanColumns[0])) return
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Ask once per physical column and retain the returned chunks. Sources
|
|
327
|
+
// report applied hints just like scan(); declined hints use the normal path.
|
|
328
|
+
/** @type {Map<string, import('../types.js').ScanColumnResults>} */
|
|
329
|
+
const columnScans = new Map()
|
|
330
|
+
for (const { column } of specs) {
|
|
331
|
+
if (columnScans.has(column)) continue
|
|
332
|
+
const options = { column, where, limit, offset, signal }
|
|
333
|
+
const result = normalizeScanColumnResult(table.scanColumn(options), options)
|
|
334
|
+
if (where && !result.appliedWhere) {
|
|
335
|
+
return { fallback: { plan: scanNode, result } }
|
|
336
|
+
}
|
|
337
|
+
if ((limit !== undefined || offset !== undefined) && !result.appliedLimitOffset) {
|
|
338
|
+
return { fallback: { plan: scanNode, result } }
|
|
314
339
|
}
|
|
340
|
+
columnScans.set(column, result)
|
|
341
|
+
}
|
|
315
342
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
343
|
+
return {
|
|
344
|
+
async *rows() {
|
|
345
|
+
/** @type {string[]} */
|
|
346
|
+
const columns = []
|
|
347
|
+
/** @type {AsyncCells} */
|
|
348
|
+
const cells = {}
|
|
349
|
+
|
|
350
|
+
// Group specs by column so each column is scanned at most once no matter how
|
|
351
|
+
// many aggregates read it (e.g. MIN(x), MAX(x), AVG(x) share one pass).
|
|
352
|
+
/** @type {Map<string, ColumnAggSpec[]>} */
|
|
353
|
+
const specsByColumn = new Map()
|
|
354
|
+
for (const spec of specs) {
|
|
355
|
+
const group = specsByColumn.get(spec.column)
|
|
356
|
+
if (group) group.push(spec)
|
|
357
|
+
else specsByColumn.set(spec.column, [spec])
|
|
329
358
|
}
|
|
330
|
-
return pass
|
|
331
|
-
}
|
|
332
359
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
360
|
+
// Each column's single pass is computed once and shared by all its cells;
|
|
361
|
+
// a column is only scanned if one of its aggregates is actually read.
|
|
362
|
+
/** @type {Map<string, Promise<Map<string, SqlPrimitive>>>} */
|
|
363
|
+
const passes = new Map()
|
|
364
|
+
/**
|
|
365
|
+
* @param {string} column
|
|
366
|
+
* @returns {Promise<Map<string, SqlPrimitive>>}
|
|
367
|
+
*/
|
|
368
|
+
function scanOnce(column) {
|
|
369
|
+
let pass = passes.get(column)
|
|
370
|
+
if (!pass) {
|
|
371
|
+
const scan = columnScans.get(column)
|
|
372
|
+
if (!scan) throw new Error(`missing scanColumn result for ${column}`)
|
|
373
|
+
pass = scanColumnGroup({ values: scan.chunks(), specs: specsByColumn.get(column) ?? [], signal })
|
|
374
|
+
passes.set(column, pass)
|
|
375
|
+
}
|
|
376
|
+
return pass
|
|
377
|
+
}
|
|
337
378
|
|
|
338
|
-
|
|
379
|
+
for (const spec of specs) {
|
|
380
|
+
columns.push(spec.alias)
|
|
381
|
+
cells[spec.alias] = async () => (await scanOnce(spec.column)).get(spec.alias) ?? null
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
yield { columns, cells }
|
|
385
|
+
},
|
|
339
386
|
}
|
|
340
387
|
}
|
|
341
388
|
|
|
@@ -344,16 +391,27 @@ function tryColumnScanAggregate(plan, { tables, signal }) {
|
|
|
344
391
|
* Returns undefined if the expression is not a supported simple aggregate.
|
|
345
392
|
*
|
|
346
393
|
* @param {DerivedColumn} col
|
|
394
|
+
* @param {string} starColumn
|
|
347
395
|
* @returns {ColumnAggSpec | undefined}
|
|
348
396
|
*/
|
|
349
|
-
function extractColumnAggSpec({ expr, alias }) {
|
|
397
|
+
function extractColumnAggSpec({ expr, alias }, starColumn) {
|
|
350
398
|
if (expr.type !== 'function') return
|
|
351
399
|
if (expr.filter) return // FILTER not supported in fast path
|
|
352
400
|
const funcName = expr.funcName.toUpperCase()
|
|
353
401
|
if (!['COUNT', 'SUM', 'AVG', 'MIN', 'MAX'].includes(funcName)) return
|
|
354
402
|
|
|
355
|
-
// Argument must be a plain column identifier
|
|
403
|
+
// Argument must be a plain column identifier, except COUNT(*), which counts
|
|
404
|
+
// the lengths of filtered chunks from an arbitrary physical column.
|
|
356
405
|
const arg = expr.args[0]
|
|
406
|
+
if (arg.type === 'star') {
|
|
407
|
+
if (funcName !== 'COUNT' || expr.distinct) return
|
|
408
|
+
return {
|
|
409
|
+
funcName,
|
|
410
|
+
column: starColumn,
|
|
411
|
+
alias: alias ?? derivedAlias(expr),
|
|
412
|
+
star: true,
|
|
413
|
+
}
|
|
414
|
+
}
|
|
357
415
|
if (arg.type !== 'identifier') return
|
|
358
416
|
return {
|
|
359
417
|
funcName,
|
|
@@ -368,26 +426,23 @@ function extractColumnAggSpec({ expr, alias }) {
|
|
|
368
426
|
* All specs share the one scanColumn walk, so MIN(x)/MAX(x)/AVG(x) decode x once.
|
|
369
427
|
*
|
|
370
428
|
* @param {Object} options
|
|
371
|
-
* @param {
|
|
429
|
+
* @param {AsyncIterable<ArrayLike<SqlPrimitive>>} options.values
|
|
372
430
|
* @param {ColumnAggSpec[]} options.specs - aggregates over the same column
|
|
373
|
-
* @param {number} [options.limit]
|
|
374
|
-
* @param {number} [options.offset]
|
|
375
431
|
* @param {AbortSignal} [options.signal]
|
|
376
432
|
* @returns {Promise<Map<string, SqlPrimitive>>} alias → aggregate value
|
|
377
433
|
*/
|
|
378
|
-
async function scanColumnGroup({
|
|
379
|
-
const { column } = specs[0]
|
|
380
|
-
const values = table.scanColumn({ column, limit, offset, signal })
|
|
381
|
-
|
|
434
|
+
async function scanColumnGroup({ values, specs, signal }) {
|
|
382
435
|
const accs = specs.map(spec => ({ spec, acc: newAccumulator(spec.funcName, spec.distinct) }))
|
|
383
436
|
|
|
384
437
|
for await (const chunk of values) {
|
|
385
438
|
signal?.throwIfAborted()
|
|
386
439
|
for (let i = 0; i < chunk.length; i++) {
|
|
387
440
|
const v = chunk[i]
|
|
388
|
-
if (v == null) continue
|
|
389
441
|
for (const { spec, acc } of accs) {
|
|
390
|
-
|
|
442
|
+
// COUNT(*) counts matching rows even when the arbitrary carrier column
|
|
443
|
+
// selected for scanColumn is null. Other aggregates ignore nulls.
|
|
444
|
+
if (spec.star) acc.count++
|
|
445
|
+
else if (v != null) updateAccumulator(spec.funcName, acc, v)
|
|
391
446
|
}
|
|
392
447
|
}
|
|
393
448
|
}
|
package/src/execute/execute.js
CHANGED
|
@@ -7,6 +7,7 @@ import { statementScope } from '../plan/columns.js'
|
|
|
7
7
|
import { validateScan, validateTable } from '../validation/tables.js'
|
|
8
8
|
import { executeHashAggregate, executeScalarAggregate } from './aggregates.js'
|
|
9
9
|
import { executeHashJoin, executeNestedLoopJoin, executePositionalJoin } from './join.js'
|
|
10
|
+
import { normalizeScanColumnResult } from './scanColumn.js'
|
|
10
11
|
import { executeSort } from './sort.js'
|
|
11
12
|
import { addBounds, minBounds, stableRowKey } from './utils.js'
|
|
12
13
|
import { executeWindow } from './window.js'
|
|
@@ -269,40 +270,63 @@ export function selectColumnNames(selectColumns, childColumns) {
|
|
|
269
270
|
/**
|
|
270
271
|
* @param {ScanNode} plan
|
|
271
272
|
* @param {ExecuteContext} context
|
|
273
|
+
* @param {import('../types.js').ScanColumnResults} [existingColumnResult]
|
|
272
274
|
* @returns {QueryResults}
|
|
273
275
|
*/
|
|
274
|
-
function executeScan(plan, context) {
|
|
276
|
+
export function executeScan(plan, context, existingColumnResult) {
|
|
275
277
|
const { tables, signal } = context
|
|
276
278
|
const table = validateTable({ ...plan, tables })
|
|
277
279
|
validateScan({ ...plan, tables })
|
|
278
280
|
const hasLimitOffset = plan.hints.limit !== undefined || plan.hints.offset // 0 offset is noop
|
|
279
281
|
|
|
280
|
-
// Fast path: single column scan
|
|
281
|
-
|
|
282
|
+
// Fast path: single column scan. As with scan(), hints the source did not
|
|
283
|
+
// apply are handled by the engine over the returned column values.
|
|
284
|
+
const scanColumnOptions = plan.hints.columns?.length === 1
|
|
285
|
+
? plan.hints.where
|
|
286
|
+
// Do not push a filtered range until WHERE is known to be applied: an
|
|
287
|
+
// older source may ignore WHERE but eagerly apply LIMIT/OFFSET.
|
|
288
|
+
? { column: plan.hints.columns[0], where: plan.hints.where, signal }
|
|
289
|
+
: { column: plan.hints.columns[0], ...plan.hints, signal }
|
|
290
|
+
: undefined
|
|
291
|
+
const columnResult = existingColumnResult ?? (table.scanColumn && scanColumnOptions
|
|
292
|
+
? normalizeScanColumnResult(table.scanColumn(scanColumnOptions), scanColumnOptions)
|
|
293
|
+
: undefined)
|
|
294
|
+
if (columnResult && scanColumnOptions) {
|
|
282
295
|
const column = plan.hints.columns[0]
|
|
283
|
-
const chunks = table.scanColumn({
|
|
284
|
-
column,
|
|
285
|
-
limit: plan.hints.limit,
|
|
286
|
-
offset: plan.hints.offset,
|
|
287
|
-
signal,
|
|
288
|
-
})
|
|
289
296
|
const scanRows = computeScanRows(table.numRows, plan.hints.limit, plan.hints.offset)
|
|
290
297
|
return {
|
|
291
298
|
columns: [column],
|
|
292
|
-
numRows: scanRows,
|
|
299
|
+
numRows: plan.hints.where ? undefined : scanRows,
|
|
293
300
|
maxRows: scanRows,
|
|
294
301
|
async *rows() {
|
|
295
302
|
const columns = [column]
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
+
let result = (async function* () {
|
|
304
|
+
// Creating the chunk stream may itself start I/O, so leave it until
|
|
305
|
+
// the returned rows are actually consumed.
|
|
306
|
+
for await (const chunk of columnResult.chunks()) {
|
|
307
|
+
signal?.throwIfAborted()
|
|
308
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
309
|
+
const value = chunk[i]
|
|
310
|
+
yield {
|
|
311
|
+
columns,
|
|
312
|
+
cells: { [column]: () => Promise.resolve(value) },
|
|
313
|
+
}
|
|
303
314
|
}
|
|
304
315
|
}
|
|
316
|
+
})()
|
|
317
|
+
|
|
318
|
+
if (!columnResult.appliedWhere && plan.hints.where) {
|
|
319
|
+
result = filterRows(result, plan.hints.where, context, plan.hints.limit)
|
|
305
320
|
}
|
|
321
|
+
// Filtered LIMIT/OFFSET was intentionally not passed to scanColumn.
|
|
322
|
+
const appliedLimitOffset = plan.hints.where
|
|
323
|
+
? false
|
|
324
|
+
: columnResult.appliedLimitOffset
|
|
325
|
+
if (!appliedLimitOffset && hasLimitOffset) {
|
|
326
|
+
result = limitRows(result, plan.hints.limit, plan.hints.offset, signal)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
yield* result
|
|
306
330
|
// A data source may end its stream cooperatively on abort; surface
|
|
307
331
|
// the abort so a truncated scan is not mistaken for a complete one
|
|
308
332
|
signal?.throwIfAborted()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalizes scanColumn's legacy AsyncIterable return into ScanColumnResults.
|
|
3
|
+
* Legacy implementations are valid for unfiltered scans only: they cannot
|
|
4
|
+
* claim to have applied a WHERE predicate they predate.
|
|
5
|
+
*
|
|
6
|
+
* @param {AsyncIterable<ArrayLike<import('../types.js').SqlPrimitive>> | import('../types.js').ScanColumnResults} result
|
|
7
|
+
* @param {import('../types.js').ScanColumnOptions} options
|
|
8
|
+
* @returns {import('../types.js').ScanColumnResults}
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeScanColumnResult(result, options) {
|
|
11
|
+
if ('chunks' in result) return result
|
|
12
|
+
return {
|
|
13
|
+
chunks: () => result,
|
|
14
|
+
appliedWhere: !options.where,
|
|
15
|
+
appliedLimitOffset: !options.where,
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -161,6 +161,8 @@ export function planStreamingAggregates({ columns, having, orderBy, groupBy }, c
|
|
|
161
161
|
case 'in valuelist':
|
|
162
162
|
// values after the first are skipped once an earlier value matches
|
|
163
163
|
return walk(node.expr, lazy) && node.values.every((v, i) => walk(v, lazy || i > 0))
|
|
164
|
+
case 'subscript':
|
|
165
|
+
return walk(node.expr, lazy) && walk(node.index, lazy)
|
|
164
166
|
case 'function': {
|
|
165
167
|
const funcName = node.funcName.toUpperCase()
|
|
166
168
|
if (!isAggregateFunc(funcName)) {
|
|
@@ -257,6 +259,8 @@ function isScalarExpr(node) {
|
|
|
257
259
|
(!node.elseResult || isScalarExpr(node.elseResult))
|
|
258
260
|
case 'in valuelist':
|
|
259
261
|
return isScalarExpr(node.expr) && node.values.every(v => isScalarExpr(v))
|
|
262
|
+
case 'subscript':
|
|
263
|
+
return isScalarExpr(node.expr) && isScalarExpr(node.index)
|
|
260
264
|
case 'function':
|
|
261
265
|
return !isAggregateFunc(node.funcName.toUpperCase()) && node.args.every(arg => isScalarExpr(arg))
|
|
262
266
|
default:
|
|
@@ -316,6 +320,11 @@ function substituteValues(node, values) {
|
|
|
316
320
|
const valueNodes = node.values.map(v => substituteValues(v, values))
|
|
317
321
|
return { ...node, expr, values: valueNodes }
|
|
318
322
|
}
|
|
323
|
+
case 'subscript': {
|
|
324
|
+
const expr = substituteValues(node.expr, values)
|
|
325
|
+
const index = substituteValues(node.index, values)
|
|
326
|
+
return expr === node.expr && index === node.index ? node : { ...node, expr, index }
|
|
327
|
+
}
|
|
319
328
|
case 'function': {
|
|
320
329
|
const args = node.args.map(arg => substituteValues(arg, values))
|
|
321
330
|
return args.every((arg, i) => arg === node.args[i]) ? node : { ...node, args }
|
package/src/expression/alias.js
CHANGED
|
@@ -37,5 +37,12 @@ export function derivedAlias(expr) {
|
|
|
37
37
|
if (expr.type === 'interval') {
|
|
38
38
|
return `interval_${expr.value}_${expr.unit.toLowerCase()}`
|
|
39
39
|
}
|
|
40
|
+
if (expr.type === 'subscript') {
|
|
41
|
+
// string subscript is struct field access, alias to the field name
|
|
42
|
+
if (expr.index.type === 'literal' && typeof expr.index.value === 'string') {
|
|
43
|
+
return expr.index.value
|
|
44
|
+
}
|
|
45
|
+
return `${derivedAlias(expr.expr)}[${derivedAlias(expr.index)}]`
|
|
46
|
+
}
|
|
40
47
|
return 'expr'
|
|
41
48
|
}
|
package/src/expression/binary.js
CHANGED
|
@@ -23,6 +23,12 @@ export function applyBinaryOp(op, a, b) {
|
|
|
23
23
|
if (op === '%') return numB === 0 ? null : numA % numB
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// String concatenation returns null if either operand is null
|
|
27
|
+
if (op === '||') {
|
|
28
|
+
if (a == null || b == null) return null
|
|
29
|
+
return String(a) + String(b)
|
|
30
|
+
}
|
|
31
|
+
|
|
26
32
|
// Comparison and logical operators
|
|
27
33
|
if (a == null || b == null) {
|
|
28
34
|
return false
|
package/src/expression/date.js
CHANGED
|
@@ -130,7 +130,7 @@ export function dateDiff(unit, startVal, endVal) {
|
|
|
130
130
|
* @param {SqlPrimitive} val
|
|
131
131
|
* @returns {Date | null}
|
|
132
132
|
*/
|
|
133
|
-
function toDate(val) {
|
|
133
|
+
export function toDate(val) {
|
|
134
134
|
if (val instanceof Date) return val
|
|
135
135
|
const dateOrTime = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?/
|
|
136
136
|
if (typeof val === 'string' && dateOrTime.test(val)) {
|
|
@@ -7,7 +7,7 @@ import { UnknownFunctionError } from '../validation/parseErrors.js'
|
|
|
7
7
|
import { ColumnNotFoundError } from '../validation/tables.js'
|
|
8
8
|
import { derivedAlias } from './alias.js'
|
|
9
9
|
import { applyBinaryOp } from './binary.js'
|
|
10
|
-
import { applyIntervalToDate, dateDiff, dateTrunc, extractField } from './date.js'
|
|
10
|
+
import { applyIntervalToDate, dateDiff, dateTrunc, extractField, toDate } from './date.js'
|
|
11
11
|
import { evaluateMathFunc } from './math.js'
|
|
12
12
|
import { evaluateRegexpFunc } from './regexp.js'
|
|
13
13
|
import { evaluateSpatialFunc } from '../spatial/spatial.js'
|
|
@@ -130,6 +130,23 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
130
130
|
})
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// Subscript access: array indexing (arr[0]) or struct field access (obj['key'])
|
|
134
|
+
if (node.type === 'subscript') {
|
|
135
|
+
const value = await evaluateExpr({ node: node.expr, row, rowIndex, rows, context })
|
|
136
|
+
if (value == null) return null
|
|
137
|
+
const index = await evaluateExpr({ node: node.index, row, rowIndex, rows, context })
|
|
138
|
+
if (index == null) return null
|
|
139
|
+
if (typeof index === 'number' || typeof index === 'bigint') {
|
|
140
|
+
if (!Array.isArray(value)) return null
|
|
141
|
+
return value[Number(index)] ?? null
|
|
142
|
+
}
|
|
143
|
+
const key = String(index)
|
|
144
|
+
if (isPlainObject(value) && Object.prototype.hasOwnProperty.call(value, key)) {
|
|
145
|
+
return value[key]
|
|
146
|
+
}
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
133
150
|
// Scalar subquery - returns a single value
|
|
134
151
|
if (node.type === 'subquery') {
|
|
135
152
|
const outerScope = context.scope
|
|
@@ -710,6 +727,14 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
710
727
|
if (toType === 'BOOLEAN' || toType === 'BOOL') {
|
|
711
728
|
return Boolean(val)
|
|
712
729
|
}
|
|
730
|
+
if (toType === 'TIMESTAMP') {
|
|
731
|
+
if (typeof val === 'number' || typeof val === 'bigint') {
|
|
732
|
+
const date = new Date(Number(val))
|
|
733
|
+
if (isNaN(date.getTime())) return null
|
|
734
|
+
return date
|
|
735
|
+
}
|
|
736
|
+
return toDate(val)
|
|
737
|
+
}
|
|
713
738
|
}
|
|
714
739
|
|
|
715
740
|
// IN and NOT IN with value lists
|
|
@@ -4,6 +4,8 @@ import { ArgValueError } from '../validation/executionErrors.js'
|
|
|
4
4
|
* @import { FunctionNode, SqlPrimitive, StringFunc } from '../types.js'
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
const textEncoder = new TextEncoder()
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* Evaluate a string function
|
|
9
11
|
*
|
|
@@ -43,6 +45,16 @@ export function evaluateStringFunc({ funcName, node, args, rowIndex }) {
|
|
|
43
45
|
})
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
if (funcName === 'OCTET_LENGTH') {
|
|
49
|
+
if (typeof val === 'string') return textEncoder.encode(val).length
|
|
50
|
+
throw new ArgValueError({
|
|
51
|
+
...node,
|
|
52
|
+
message: `expected string, got ${typeof val === 'object' ? val instanceof Date ? 'date' : 'object' : typeof val}`,
|
|
53
|
+
hint: 'Use CAST to convert to a string first.',
|
|
54
|
+
rowIndex,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
46
58
|
if (typeof val === 'object' && !(val instanceof Date)) {
|
|
47
59
|
throw new ArgValueError({
|
|
48
60
|
...node,
|
|
@@ -131,6 +143,32 @@ export function evaluateStringFunc({ funcName, node, args, rowIndex }) {
|
|
|
131
143
|
return str.substring(str.length - len)
|
|
132
144
|
}
|
|
133
145
|
|
|
146
|
+
if (funcName === 'SPLIT_PART') {
|
|
147
|
+
const delimiter = args[1]
|
|
148
|
+
if (delimiter == null) return null
|
|
149
|
+
const index = Number(args[2])
|
|
150
|
+
if (!Number.isInteger(index) || index === 0) {
|
|
151
|
+
throw new ArgValueError({
|
|
152
|
+
...node,
|
|
153
|
+
message: `index must be a non-zero integer, got ${args[2]}`,
|
|
154
|
+
hint: 'Field indexes are 1-based.',
|
|
155
|
+
rowIndex,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
const delim = String(delimiter)
|
|
159
|
+
const parts = delim === '' ? [str] : str.split(delim)
|
|
160
|
+
// Negative index counts from the end
|
|
161
|
+
const partIdx = index < 0 ? parts.length + index : index - 1
|
|
162
|
+
return parts[partIdx] ?? ''
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (funcName === 'STRING_SPLIT') {
|
|
166
|
+
const delimiter = args[1]
|
|
167
|
+
if (delimiter == null) return null
|
|
168
|
+
const delim = String(delimiter)
|
|
169
|
+
return delim === '' ? [str] : str.split(delim)
|
|
170
|
+
}
|
|
171
|
+
|
|
134
172
|
if (funcName === 'INSTR' || funcName === 'POSITION' || funcName === 'STRPOS') {
|
|
135
173
|
const search = args[1]
|
|
136
174
|
if (search == null) return null
|
package/src/parse/expression.js
CHANGED
|
@@ -9,7 +9,7 @@ import { consume, current, expect, match } from './state.js'
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
// Precedence (lowest to highest):
|
|
12
|
-
// OR, AND, NOT, Comparison, Additive, Multiplicative, Primary
|
|
12
|
+
// OR, AND, NOT, Comparison, Concat, Additive, Multiplicative, Primary
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* @param {ParserState} state
|
|
@@ -88,7 +88,7 @@ function parseNot(state) {
|
|
|
88
88
|
* @returns {ExprNode}
|
|
89
89
|
*/
|
|
90
90
|
function parseComparison(state) {
|
|
91
|
-
const left =
|
|
91
|
+
const left = parseConcat(state)
|
|
92
92
|
const { positionStart } = left
|
|
93
93
|
|
|
94
94
|
// IS [NOT] NULL
|
|
@@ -108,7 +108,7 @@ function parseComparison(state) {
|
|
|
108
108
|
if (match(state, 'keyword', 'NOT')) {
|
|
109
109
|
// NOT LIKE
|
|
110
110
|
if (match(state, 'keyword', 'LIKE')) {
|
|
111
|
-
const right =
|
|
111
|
+
const right = parseConcat(state)
|
|
112
112
|
return {
|
|
113
113
|
type: 'unary',
|
|
114
114
|
op: 'NOT',
|
|
@@ -127,9 +127,9 @@ function parseComparison(state) {
|
|
|
127
127
|
|
|
128
128
|
// NOT BETWEEN - convert to range comparison
|
|
129
129
|
if (match(state, 'keyword', 'BETWEEN')) {
|
|
130
|
-
const lower =
|
|
130
|
+
const lower = parseConcat(state)
|
|
131
131
|
expect(state, 'keyword', 'AND')
|
|
132
|
-
const upper =
|
|
132
|
+
const upper = parseConcat(state)
|
|
133
133
|
// NOT BETWEEN -> expr < lower OR expr > upper
|
|
134
134
|
return {
|
|
135
135
|
type: 'binary',
|
|
@@ -163,7 +163,7 @@ function parseComparison(state) {
|
|
|
163
163
|
|
|
164
164
|
// LIKE
|
|
165
165
|
if (match(state, 'keyword', 'LIKE')) {
|
|
166
|
-
const right =
|
|
166
|
+
const right = parseConcat(state)
|
|
167
167
|
return {
|
|
168
168
|
type: 'binary',
|
|
169
169
|
op: 'LIKE',
|
|
@@ -176,9 +176,9 @@ function parseComparison(state) {
|
|
|
176
176
|
|
|
177
177
|
// BETWEEN - convert to range comparison
|
|
178
178
|
if (match(state, 'keyword', 'BETWEEN')) {
|
|
179
|
-
const lower =
|
|
179
|
+
const lower = parseConcat(state)
|
|
180
180
|
expect(state, 'keyword', 'AND')
|
|
181
|
-
const upper =
|
|
181
|
+
const upper = parseConcat(state)
|
|
182
182
|
// BETWEEN -> expr >= lower AND expr <= upper
|
|
183
183
|
return {
|
|
184
184
|
type: 'binary',
|
|
@@ -198,7 +198,7 @@ function parseComparison(state) {
|
|
|
198
198
|
const opTok = current(state)
|
|
199
199
|
if (opTok.type === 'operator' && isBinaryOp(opTok.value)) {
|
|
200
200
|
consume(state)
|
|
201
|
-
const right =
|
|
201
|
+
const right = parseConcat(state)
|
|
202
202
|
return {
|
|
203
203
|
type: 'binary',
|
|
204
204
|
op: opTok.value,
|
|
@@ -212,6 +212,33 @@ function parseComparison(state) {
|
|
|
212
212
|
return left
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
/**
|
|
216
|
+
* @param {ParserState} state
|
|
217
|
+
* @returns {ExprNode}
|
|
218
|
+
*/
|
|
219
|
+
export function parseConcat(state) {
|
|
220
|
+
let left = parseAdditive(state)
|
|
221
|
+
while (true) {
|
|
222
|
+
const tok = current(state)
|
|
223
|
+
if (tok.type === 'operator' && tok.value === '||') {
|
|
224
|
+
consume(state)
|
|
225
|
+
const right = parseAdditive(state)
|
|
226
|
+
// Recursive left-associative binary operator
|
|
227
|
+
left = {
|
|
228
|
+
type: 'binary',
|
|
229
|
+
op: '||',
|
|
230
|
+
left,
|
|
231
|
+
right,
|
|
232
|
+
positionStart: left.positionStart,
|
|
233
|
+
positionEnd: right.positionEnd,
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
break
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return left
|
|
240
|
+
}
|
|
241
|
+
|
|
215
242
|
/**
|
|
216
243
|
* @param {ParserState} state
|
|
217
244
|
* @returns {ExprNode}
|
|
@@ -110,6 +110,10 @@ function walkExpr(expr, cteScope, refs) {
|
|
|
110
110
|
walkExpr(expr.expr, cteScope, refs)
|
|
111
111
|
for (const v of expr.values) walkExpr(v, cteScope, refs)
|
|
112
112
|
return
|
|
113
|
+
case 'subscript':
|
|
114
|
+
walkExpr(expr.expr, cteScope, refs)
|
|
115
|
+
walkExpr(expr.index, cteScope, refs)
|
|
116
|
+
return
|
|
113
117
|
case 'exists':
|
|
114
118
|
case 'not exists':
|
|
115
119
|
walkStatement(expr.subquery, cteScope, refs)
|
package/src/parse/functions.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isAggregateFunc, isKnownFunction, isWindowFunc, niladicFuncs, validateFunctionArgs } from '../validation/functions.js'
|
|
2
2
|
import { ParseError, UnknownFunctionError } from '../validation/parseErrors.js'
|
|
3
|
-
import { parseExpression } from './expression.js'
|
|
3
|
+
import { parseConcat, parseExpression } from './expression.js'
|
|
4
4
|
import { consume, current, expect, match } from './state.js'
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -60,6 +60,17 @@ export function parseFunctionCall(state, positionStart) {
|
|
|
60
60
|
positionStart: next.positionStart,
|
|
61
61
|
positionEnd: state.lastPos,
|
|
62
62
|
})
|
|
63
|
+
} else if (funcNameUpper === 'POSITION' && !args.length) {
|
|
64
|
+
// ANSI syntax: POSITION(needle IN haystack), stored as (haystack, needle).
|
|
65
|
+
// The needle is parsed below comparison precedence so IN is not consumed
|
|
66
|
+
// as the set-membership operator.
|
|
67
|
+
const needle = parseConcat(state)
|
|
68
|
+
if (match(state, 'keyword', 'IN')) {
|
|
69
|
+
const haystack = parseExpression(state)
|
|
70
|
+
args.push(haystack, needle)
|
|
71
|
+
break
|
|
72
|
+
}
|
|
73
|
+
args.push(needle)
|
|
63
74
|
} else {
|
|
64
75
|
args.push(parseExpression(state))
|
|
65
76
|
}
|
package/src/parse/parse.js
CHANGED
|
@@ -48,6 +48,14 @@ export function parseSql({ query, functions }) {
|
|
|
48
48
|
export function parseStatement(state) {
|
|
49
49
|
const positionStart = state.lastPos
|
|
50
50
|
if (match(state, 'keyword', 'WITH')) {
|
|
51
|
+
const recursiveTok = current(state)
|
|
52
|
+
if (recursiveTok.type === 'identifier' && recursiveTok.value.toUpperCase() === 'RECURSIVE') {
|
|
53
|
+
throw new ParseError({
|
|
54
|
+
message: `WITH RECURSIVE is not supported at position ${recursiveTok.positionStart}`,
|
|
55
|
+
...recursiveTok,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
51
59
|
/** @type {CTEDefinition[]} */
|
|
52
60
|
const ctes = []
|
|
53
61
|
/** @type {Set<string>} */
|
package/src/parse/primary.js
CHANGED
|
@@ -11,12 +11,65 @@ import { consume, current, expect, match, parseError, peekToken } from './state.
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* Parse a primary expression, which is the innermost order of operations
|
|
14
|
+
* Parse a primary expression, which is the innermost order of operations,
|
|
15
|
+
* followed by any postfix subscript operators.
|
|
15
16
|
*
|
|
16
17
|
* @param {ParserState} state
|
|
17
18
|
* @returns {ExprNode}
|
|
18
19
|
*/
|
|
19
20
|
export function parsePrimary(state) {
|
|
21
|
+
return parsePostfix(state, parsePrimaryBase(state))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parses postfix subscript operators after a primary expression: bracket
|
|
26
|
+
* indexing (`expr[0]`, `expr['key']`) and struct field access on a subscript
|
|
27
|
+
* result (`expr[0].field`).
|
|
28
|
+
*
|
|
29
|
+
* @param {ParserState} state
|
|
30
|
+
* @param {ExprNode} expr
|
|
31
|
+
* @returns {ExprNode}
|
|
32
|
+
*/
|
|
33
|
+
function parsePostfix(state, expr) {
|
|
34
|
+
while (true) {
|
|
35
|
+
if (match(state, 'bracket', '[')) {
|
|
36
|
+
const index = parseExpression(state)
|
|
37
|
+
expect(state, 'bracket', ']')
|
|
38
|
+
expr = {
|
|
39
|
+
type: 'subscript',
|
|
40
|
+
expr,
|
|
41
|
+
index,
|
|
42
|
+
positionStart: expr.positionStart,
|
|
43
|
+
positionEnd: state.lastPos,
|
|
44
|
+
}
|
|
45
|
+
} else if (expr.type === 'subscript' && current(state).type === 'dot') {
|
|
46
|
+
consume(state)
|
|
47
|
+
const fieldTok = expect(state, 'identifier')
|
|
48
|
+
expr = {
|
|
49
|
+
type: 'subscript',
|
|
50
|
+
expr,
|
|
51
|
+
index: {
|
|
52
|
+
type: 'literal',
|
|
53
|
+
value: fieldTok.value,
|
|
54
|
+
positionStart: fieldTok.positionStart,
|
|
55
|
+
positionEnd: fieldTok.positionEnd,
|
|
56
|
+
},
|
|
57
|
+
positionStart: expr.positionStart,
|
|
58
|
+
positionEnd: state.lastPos,
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
return expr
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse a primary expression without postfix operators.
|
|
68
|
+
*
|
|
69
|
+
* @param {ParserState} state
|
|
70
|
+
* @returns {ExprNode}
|
|
71
|
+
*/
|
|
72
|
+
function parsePrimaryBase(state) {
|
|
20
73
|
const tok = current(state)
|
|
21
74
|
const { positionStart } = tok
|
|
22
75
|
|
|
@@ -82,7 +135,7 @@ export function parsePrimary(state) {
|
|
|
82
135
|
const toType = typeTok.value.toUpperCase()
|
|
83
136
|
if (!isCastType(toType)) {
|
|
84
137
|
throw new SyntaxError({
|
|
85
|
-
expected: 'cast type (STRING, INT, BIGINT, FLOAT, BOOL)',
|
|
138
|
+
expected: 'cast type (STRING, INT, BIGINT, FLOAT, BOOL, TIMESTAMP)',
|
|
86
139
|
after: 'AS',
|
|
87
140
|
...typeTok,
|
|
88
141
|
})
|
|
@@ -97,6 +150,24 @@ export function parsePrimary(state) {
|
|
|
97
150
|
}
|
|
98
151
|
}
|
|
99
152
|
|
|
153
|
+
// TIMESTAMP '2026-01-01 00:00:00' typed literal
|
|
154
|
+
if (funcNameUpper === 'TIMESTAMP' && next.type === 'string') {
|
|
155
|
+
consume(state) // TIMESTAMP
|
|
156
|
+
const strTok = consume(state) // string literal
|
|
157
|
+
return {
|
|
158
|
+
type: 'cast',
|
|
159
|
+
expr: {
|
|
160
|
+
type: 'literal',
|
|
161
|
+
value: strTok.value,
|
|
162
|
+
positionStart: strTok.positionStart,
|
|
163
|
+
positionEnd: strTok.positionEnd,
|
|
164
|
+
},
|
|
165
|
+
toType: 'TIMESTAMP',
|
|
166
|
+
positionStart,
|
|
167
|
+
positionEnd: state.lastPos,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
100
171
|
// EXTRACT(field FROM expr)
|
|
101
172
|
if (funcNameUpper === 'EXTRACT' && next.type === 'paren' && next.value === '(') {
|
|
102
173
|
consume(state) // EXTRACT
|
|
@@ -157,16 +228,21 @@ export function parsePrimary(state) {
|
|
|
157
228
|
if (match(state, 'dot')) {
|
|
158
229
|
prefix = name
|
|
159
230
|
name = expect(state, 'identifier').value
|
|
160
|
-
} else
|
|
161
|
-
// table['column'] string subscript is equivalent to dot access
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
231
|
+
} else {
|
|
232
|
+
// table['column'] string subscript is equivalent to dot access; other
|
|
233
|
+
// subscripts (numeric, expression) are left for the postfix parser
|
|
234
|
+
const open = current(state)
|
|
235
|
+
const fieldTok = peekToken(state, 1)
|
|
236
|
+
const close = peekToken(state, 2)
|
|
237
|
+
if (open.type === 'bracket' && open.value === '[' &&
|
|
238
|
+
fieldTok.type === 'string' &&
|
|
239
|
+
close.type === 'bracket' && close.value === ']') {
|
|
240
|
+
consume(state) // '['
|
|
241
|
+
consume(state) // string
|
|
242
|
+
consume(state) // ']'
|
|
243
|
+
prefix = name
|
|
244
|
+
name = fieldTok.value
|
|
165
245
|
}
|
|
166
|
-
consume(state)
|
|
167
|
-
expect(state, 'bracket', ']')
|
|
168
|
-
prefix = name
|
|
169
|
-
name = fieldTok.value
|
|
170
246
|
}
|
|
171
247
|
|
|
172
248
|
return {
|
package/src/parse/tokenize.js
CHANGED
|
@@ -172,6 +172,18 @@ export function tokenizeSql(query) {
|
|
|
172
172
|
continue
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
// string concatenation operator
|
|
176
|
+
if (ch === '|' && next === '|') {
|
|
177
|
+
i += 2
|
|
178
|
+
tokens.push({
|
|
179
|
+
type: 'operator',
|
|
180
|
+
value: '||',
|
|
181
|
+
positionStart,
|
|
182
|
+
positionEnd: i,
|
|
183
|
+
})
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
|
|
175
187
|
// operators
|
|
176
188
|
if ('<>!=+-*/%'.includes(ch)) {
|
|
177
189
|
let op = nextChar()
|
package/src/plan/columns.js
CHANGED
|
@@ -255,6 +255,9 @@ export function collectColumnsFromExpr(expr, columns, aliases) {
|
|
|
255
255
|
}
|
|
256
256
|
} else if (expr.type === 'in') {
|
|
257
257
|
collectColumnsFromExpr(expr.expr, columns, aliases)
|
|
258
|
+
} else if (expr.type === 'subscript') {
|
|
259
|
+
collectColumnsFromExpr(expr.expr, columns, aliases)
|
|
260
|
+
collectColumnsFromExpr(expr.index, columns, aliases)
|
|
258
261
|
} else if (expr.type === 'case') {
|
|
259
262
|
if (expr.caseExpr) {
|
|
260
263
|
collectColumnsFromExpr(expr.caseExpr, columns, aliases)
|
package/src/plan/plan.js
CHANGED
|
@@ -637,6 +637,9 @@ function resolveAliases(node, aliases) {
|
|
|
637
637
|
const values = node.values.map(v => resolveAliases(v, aliases))
|
|
638
638
|
return { ...node, expr, values }
|
|
639
639
|
}
|
|
640
|
+
if (node.type === 'subscript') {
|
|
641
|
+
return { ...node, expr: resolveAliases(node.expr, aliases), index: resolveAliases(node.index, aliases) }
|
|
642
|
+
}
|
|
640
643
|
if (node.type === 'case') {
|
|
641
644
|
const caseExpr = resolveAliases(node.caseExpr, aliases)
|
|
642
645
|
const whenClauses = node.whenClauses.map(w => {
|
|
@@ -697,6 +700,13 @@ function normalizeIdentifiers(node, sourceColumns) {
|
|
|
697
700
|
values: node.values.map(v => normalizeIdentifiers(v, sourceColumns)),
|
|
698
701
|
}
|
|
699
702
|
}
|
|
703
|
+
if (node.type === 'subscript') {
|
|
704
|
+
return {
|
|
705
|
+
...node,
|
|
706
|
+
expr: normalizeIdentifiers(node.expr, sourceColumns),
|
|
707
|
+
index: normalizeIdentifiers(node.index, sourceColumns),
|
|
708
|
+
}
|
|
709
|
+
}
|
|
700
710
|
if (node.type === 'case') {
|
|
701
711
|
return {
|
|
702
712
|
...node,
|
|
@@ -872,6 +882,9 @@ function validateLateralSubqueries({ expr, ctePlans, cteColumns, tables, outerSc
|
|
|
872
882
|
for (const val of expr.values) {
|
|
873
883
|
validateLateralSubqueries({ expr: val, ctePlans, cteColumns, tables, outerScope })
|
|
874
884
|
}
|
|
885
|
+
} else if (expr.type === 'subscript') {
|
|
886
|
+
validateLateralSubqueries({ expr: expr.expr, ctePlans, cteColumns, tables, outerScope })
|
|
887
|
+
validateLateralSubqueries({ expr: expr.index, ctePlans, cteColumns, tables, outerScope })
|
|
875
888
|
} else if (expr.type === 'case') {
|
|
876
889
|
validateLateralSubqueries({ expr: expr.caseExpr, ctePlans, cteColumns, tables, outerScope })
|
|
877
890
|
for (const w of expr.whenClauses) {
|
|
@@ -930,6 +943,13 @@ function collectWindows(expr, windows) {
|
|
|
930
943
|
values: expr.values.map(v => collectWindows(v, windows)),
|
|
931
944
|
}
|
|
932
945
|
}
|
|
946
|
+
if (expr.type === 'subscript') {
|
|
947
|
+
return {
|
|
948
|
+
...expr,
|
|
949
|
+
expr: collectWindows(expr.expr, windows),
|
|
950
|
+
index: collectWindows(expr.index, windows),
|
|
951
|
+
}
|
|
952
|
+
}
|
|
933
953
|
if (expr.type === 'case') {
|
|
934
954
|
return {
|
|
935
955
|
...expr,
|
|
@@ -979,6 +999,7 @@ function findWindow(expr) {
|
|
|
979
999
|
return undefined
|
|
980
1000
|
}
|
|
981
1001
|
if (expr.type === 'cast') return findWindow(expr.expr)
|
|
1002
|
+
if (expr.type === 'subscript') return findWindow(expr.expr) || findWindow(expr.index)
|
|
982
1003
|
if (expr.type === 'in valuelist') {
|
|
983
1004
|
const found = findWindow(expr.expr)
|
|
984
1005
|
if (found) return found
|
package/src/types.d.ts
CHANGED
|
@@ -79,7 +79,7 @@ export interface AsyncDataSource {
|
|
|
79
79
|
columns: string[]
|
|
80
80
|
scan(options: ScanOptions): ScanResults
|
|
81
81
|
// Optional method for fast column scans
|
|
82
|
-
scanColumn?(options: ScanColumnOptions): AsyncIterable<ArrayLike<SqlPrimitive>>
|
|
82
|
+
scanColumn?(options: ScanColumnOptions): AsyncIterable<ArrayLike<SqlPrimitive>> | ScanColumnResults
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/**
|
|
@@ -113,11 +113,19 @@ export interface ScanOptions {
|
|
|
113
113
|
*/
|
|
114
114
|
export interface ScanColumnOptions {
|
|
115
115
|
column: string
|
|
116
|
+
where?: ExprNode
|
|
116
117
|
limit?: number
|
|
117
118
|
offset?: number
|
|
118
119
|
signal?: AbortSignal
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
/** Result of a column scan, mirroring the ScanResults hint flags. */
|
|
123
|
+
export interface ScanColumnResults {
|
|
124
|
+
chunks(): AsyncIterable<ArrayLike<SqlPrimitive>>
|
|
125
|
+
appliedWhere: boolean
|
|
126
|
+
appliedLimitOffset: boolean
|
|
127
|
+
}
|
|
128
|
+
|
|
121
129
|
export interface FunctionSignature {
|
|
122
130
|
min: number
|
|
123
131
|
max?: number
|
|
@@ -166,6 +174,7 @@ export type StringFunc =
|
|
|
166
174
|
| 'LOWER'
|
|
167
175
|
| 'CONCAT'
|
|
168
176
|
| 'LENGTH'
|
|
177
|
+
| 'OCTET_LENGTH'
|
|
169
178
|
| 'SUBSTRING'
|
|
170
179
|
| 'SUBSTR'
|
|
171
180
|
| 'TRIM'
|
|
@@ -175,6 +184,8 @@ export type StringFunc =
|
|
|
175
184
|
| 'INSTR'
|
|
176
185
|
| 'POSITION'
|
|
177
186
|
| 'STRPOS'
|
|
187
|
+
| 'SPLIT_PART'
|
|
188
|
+
| 'STRING_SPLIT'
|
|
178
189
|
|
|
179
190
|
export type SpatialFunc =
|
|
180
191
|
| 'ST_INTERSECTS'
|
|
@@ -31,6 +31,9 @@ export function findAggregate(expr) {
|
|
|
31
31
|
if (expr.type === 'cast') {
|
|
32
32
|
return findAggregate(expr.expr)
|
|
33
33
|
}
|
|
34
|
+
if (expr.type === 'subscript') {
|
|
35
|
+
return findAggregate(expr.expr) || findAggregate(expr.index)
|
|
36
|
+
}
|
|
34
37
|
if (expr.type === 'case') {
|
|
35
38
|
if (expr.caseExpr) {
|
|
36
39
|
const found = findAggregate(expr.caseExpr)
|
|
@@ -86,7 +86,7 @@ export function isExtractField(name) {
|
|
|
86
86
|
* @returns {name is CastType}
|
|
87
87
|
*/
|
|
88
88
|
export function isCastType(name) {
|
|
89
|
-
return ['TEXT', 'STRING', 'VARCHAR', 'INTEGER', 'INT', 'BIGINT', 'FLOAT', 'REAL', 'DOUBLE', 'BOOLEAN', 'BOOL'].includes(name)
|
|
89
|
+
return ['TEXT', 'STRING', 'VARCHAR', 'INTEGER', 'INT', 'BIGINT', 'FLOAT', 'REAL', 'DOUBLE', 'BOOLEAN', 'BOOL', 'TIMESTAMP'].includes(name)
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
@@ -95,8 +95,8 @@ export function isCastType(name) {
|
|
|
95
95
|
*/
|
|
96
96
|
export function isStringFunc(name) {
|
|
97
97
|
return [
|
|
98
|
-
'UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'SUBSTRING', 'SUBSTR', 'TRIM',
|
|
99
|
-
'REPLACE', 'LEFT', 'RIGHT', 'INSTR', 'POSITION', 'STRPOS',
|
|
98
|
+
'UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'OCTET_LENGTH', 'SUBSTRING', 'SUBSTR', 'TRIM',
|
|
99
|
+
'REPLACE', 'LEFT', 'RIGHT', 'INSTR', 'POSITION', 'STRPOS', 'SPLIT_PART', 'STRING_SPLIT',
|
|
100
100
|
].includes(name)
|
|
101
101
|
}
|
|
102
102
|
|
|
@@ -117,6 +117,7 @@ export const FUNCTION_SIGNATURES = {
|
|
|
117
117
|
UPPER: { min: 1, max: 1, signature: 'string' },
|
|
118
118
|
LOWER: { min: 1, max: 1, signature: 'string' },
|
|
119
119
|
LENGTH: { min: 1, max: 1, signature: 'string' },
|
|
120
|
+
OCTET_LENGTH: { min: 1, max: 1, signature: 'string' },
|
|
120
121
|
TRIM: { min: 1, max: 1, signature: 'string' },
|
|
121
122
|
REPLACE: { min: 3, max: 3, signature: 'string, search, replacement' },
|
|
122
123
|
SUBSTRING: { min: 2, max: 3, signature: 'string, start[, length]' },
|
|
@@ -127,6 +128,8 @@ export const FUNCTION_SIGNATURES = {
|
|
|
127
128
|
INSTR: { min: 2, max: 2, signature: 'string, substring' },
|
|
128
129
|
POSITION: { min: 2, max: 2, signature: 'string, substring' },
|
|
129
130
|
STRPOS: { min: 2, max: 2, signature: 'string, substring' },
|
|
131
|
+
SPLIT_PART: { min: 3, max: 3, signature: 'string, delimiter, index' },
|
|
132
|
+
STRING_SPLIT: { min: 2, max: 2, signature: 'string, delimiter' },
|
|
130
133
|
REGEXP_SUBSTR: { min: 2, max: 4, signature: 'string, pattern[, position[, occurrence]]' },
|
|
131
134
|
REGEXP_EXTRACT: { min: 2, max: 4, signature: 'string, pattern[, position[, occurrence]]' },
|
|
132
135
|
REGEXP_REPLACE: { min: 3, max: 5, signature: 'string, pattern, replacement[, position[, occurrence]]' },
|
package/src/validation/tables.js
CHANGED
|
@@ -90,6 +90,9 @@ export function validateNoIdentifiers(expr, context, outerScope) {
|
|
|
90
90
|
} else if (expr.type === 'in') {
|
|
91
91
|
// LHS is in our scope; subquery is self-contained and planned separately.
|
|
92
92
|
validateNoIdentifiers(expr.expr, context, outerScope)
|
|
93
|
+
} else if (expr.type === 'subscript') {
|
|
94
|
+
validateNoIdentifiers(expr.expr, context, outerScope)
|
|
95
|
+
validateNoIdentifiers(expr.index, context, outerScope)
|
|
93
96
|
} else if (expr.type === 'case') {
|
|
94
97
|
validateNoIdentifiers(expr.caseExpr, context, outerScope)
|
|
95
98
|
for (const w of expr.whenClauses) {
|
|
@@ -144,6 +147,9 @@ export function validateTableRefs(expr, tables, scopeColumns) {
|
|
|
144
147
|
for (const val of expr.values) {
|
|
145
148
|
validateTableRefs(val, tables, scopeColumns)
|
|
146
149
|
}
|
|
150
|
+
} else if (expr.type === 'subscript') {
|
|
151
|
+
validateTableRefs(expr.expr, tables, scopeColumns)
|
|
152
|
+
validateTableRefs(expr.index, tables, scopeColumns)
|
|
147
153
|
} else if (expr.type === 'case') {
|
|
148
154
|
validateTableRefs(expr.caseExpr, tables, scopeColumns)
|
|
149
155
|
for (const w of expr.whenClauses) {
|