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 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.13.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.0",
43
- "@vitest/coverage-v8": "4.1.9",
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.11",
46
- "typescript": "6.0.3",
47
- "vitest": "4.1.9"
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 fast = tryColumnScanAggregate(plan, context)
199
- if (fast) {
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: fast,
205
+ rows: columnScan.rows,
205
206
  }
206
207
  }
207
208
 
208
- const child = executePlan({ plan: plan.child, context })
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 {(() => AsyncGenerator<AsyncRow>) | undefined}
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
- return async function* () {
301
- /** @type {string[]} */
302
- const columns = []
303
- /** @type {AsyncCells} */
304
- const cells = {}
305
-
306
- // Group specs by column so each column is scanned at most once no matter how
307
- // many aggregates read it (e.g. MIN(x), MAX(x), AVG(x) share one pass).
308
- /** @type {Map<string, ColumnAggSpec[]>} */
309
- const specsByColumn = new Map()
310
- for (const spec of specs) {
311
- const group = specsByColumn.get(spec.column)
312
- if (group) group.push(spec)
313
- else specsByColumn.set(spec.column, [spec])
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
- // Each column's single pass is computed once and shared by all its cells;
317
- // a column is only scanned if one of its aggregates is actually read.
318
- /** @type {Map<string, Promise<Map<string, SqlPrimitive>>>} */
319
- const passes = new Map()
320
- /**
321
- * @param {string} column
322
- * @returns {Promise<Map<string, SqlPrimitive>>}
323
- */
324
- function scanOnce(column) {
325
- let pass = passes.get(column)
326
- if (!pass) {
327
- pass = scanColumnGroup({ table, specs: specsByColumn.get(column) ?? [], limit, offset, signal })
328
- passes.set(column, pass)
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
- for (const spec of specs) {
334
- columns.push(spec.alias)
335
- cells[spec.alias] = async () => (await scanOnce(spec.column)).get(spec.alias) ?? null
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
- yield { columns, cells }
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 {AsyncDataSource} options.table
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({ table, specs, limit, offset, signal }) {
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
- updateAccumulator(spec.funcName, acc, v)
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
  }
@@ -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 without WHERE
281
- if (table.scanColumn && plan.hints.columns?.length === 1 && !plan.hints.where) {
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
- for await (const chunk of chunks) {
297
- signal?.throwIfAborted()
298
- for (let i = 0; i < chunk.length; i++) {
299
- const value = chunk[i]
300
- yield {
301
- columns,
302
- cells: { [column]: () => Promise.resolve(value) },
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 }
@@ -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
  }
@@ -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
@@ -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
@@ -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 = parseAdditive(state)
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 = parseAdditive(state)
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 = parseAdditive(state)
130
+ const lower = parseConcat(state)
131
131
  expect(state, 'keyword', 'AND')
132
- const upper = parseAdditive(state)
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 = parseAdditive(state)
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 = parseAdditive(state)
179
+ const lower = parseConcat(state)
180
180
  expect(state, 'keyword', 'AND')
181
- const upper = parseAdditive(state)
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 = parseAdditive(state)
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)
@@ -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
  }
@@ -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>} */
@@ -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 if (match(state, 'bracket', '[')) {
161
- // table['column'] string subscript is equivalent to dot access
162
- const fieldTok = current(state)
163
- if (fieldTok.type !== 'string') {
164
- throw parseError(state, 'string literal')
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 {
@@ -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()
@@ -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]]' },
@@ -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) {