squirreling 0.12.27 → 0.14.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.12.27",
3
+ "version": "0.14.0",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -40,10 +40,10 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/node": "26.1.0",
43
- "@vitest/coverage-v8": "4.1.9",
43
+ "@vitest/coverage-v8": "4.1.10",
44
44
  "eslint": "9.39.4",
45
- "eslint-plugin-jsdoc": "63.0.10",
45
+ "eslint-plugin-jsdoc": "63.0.12",
46
46
  "typescript": "6.0.3",
47
- "vitest": "4.1.9"
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'
@@ -101,7 +101,7 @@ export function executeHashAggregate(plan, context) {
101
101
  for await (const row of child.rows()) {
102
102
  if (++collectCount % YIELD_INTERVAL === 0) {
103
103
  await yieldToEventLoop()
104
- if (context.signal?.aborted) return
104
+ context.signal?.throwIfAborted()
105
105
  }
106
106
  allRows.push(row)
107
107
  }
@@ -121,7 +121,7 @@ export function executeHashAggregate(plan, context) {
121
121
  for (let chunkStart = 0; chunkStart < allRows.length; chunkStart += YIELD_INTERVAL) {
122
122
  if (chunkStart > 0) {
123
123
  await yieldToEventLoop()
124
- if (context.signal?.aborted) return
124
+ context.signal?.throwIfAborted()
125
125
  }
126
126
  const chunkEnd = Math.min(chunkStart + YIELD_INTERVAL, allRows.length)
127
127
  const chunkLen = chunkEnd - chunkStart
@@ -227,7 +227,7 @@ export function executeScalarAggregate(plan, context) {
227
227
  for await (const row of child.rows()) {
228
228
  if (++collectCount % YIELD_INTERVAL === 0) {
229
229
  await yieldToEventLoop()
230
- if (context.signal?.aborted) return
230
+ context.signal?.throwIfAborted()
231
231
  }
232
232
  group.push(row)
233
233
  }
@@ -174,7 +174,7 @@ function executeUnnest(plan, context) {
174
174
  const value = await evaluateExpr({ node: plan.args[0], row, rowIndex: 1, context })
175
175
  if (!Array.isArray(value)) return
176
176
  for (const element of value) {
177
- if (context.signal?.aborted) return
177
+ context.signal?.throwIfAborted()
178
178
  yield {
179
179
  columns,
180
180
  cells: { [columnName]: () => Promise.resolve(element) },
@@ -209,7 +209,7 @@ function executeJsonEach(plan, context) {
209
209
  }
210
210
  if (Array.isArray(parsed)) {
211
211
  for (let i = 0; i < parsed.length; i++) {
212
- if (context.signal?.aborted) return
212
+ context.signal?.throwIfAborted()
213
213
  const k = i
214
214
  const v = parsed[i]
215
215
  yield {
@@ -224,7 +224,7 @@ function executeJsonEach(plan, context) {
224
224
  }
225
225
  if (typeof parsed === 'object' && parsed !== null) {
226
226
  for (const [k, v] of Object.entries(parsed)) {
227
- if (context.signal?.aborted) return
227
+ context.signal?.throwIfAborted()
228
228
  yield {
229
229
  columns,
230
230
  cells: {
@@ -294,7 +294,7 @@ function executeScan(plan, context) {
294
294
  async *rows() {
295
295
  const columns = [column]
296
296
  for await (const chunk of chunks) {
297
- if (signal?.aborted) return
297
+ signal?.throwIfAborted()
298
298
  for (let i = 0; i < chunk.length; i++) {
299
299
  const value = chunk[i]
300
300
  yield {
@@ -303,6 +303,9 @@ function executeScan(plan, context) {
303
303
  }
304
304
  }
305
305
  }
306
+ // A data source may end its stream cooperatively on abort; surface
307
+ // the abort so a truncated scan is not mistaken for a complete one
308
+ signal?.throwIfAborted()
306
309
  },
307
310
  }
308
311
  }
@@ -335,6 +338,10 @@ function executeScan(plan, context) {
335
338
  }
336
339
 
337
340
  yield* result
341
+
342
+ // A data source may end its stream cooperatively on abort; surface
343
+ // the abort so a truncated scan is not mistaken for a complete one
344
+ signal?.throwIfAborted()
338
345
  },
339
346
  }
340
347
  }
@@ -420,10 +427,10 @@ async function* filterRows(rows, condition, context, limit) {
420
427
  let buffer = []
421
428
 
422
429
  for await (const row of rows) {
423
- if (context.signal?.aborted) return
430
+ context.signal?.throwIfAborted()
424
431
  if (++innerCount % YIELD_INTERVAL === 0) {
425
432
  await yieldToEventLoop()
426
- if (context.signal?.aborted) return
433
+ context.signal?.throwIfAborted()
427
434
  }
428
435
  rowIndex++
429
436
  buffer.push({ row, rowIndex })
@@ -466,10 +473,10 @@ async function* limitRows(rows, limit = Infinity, offset = 0, signal) {
466
473
  let yielded = 0
467
474
  let innerCount = 0
468
475
  for await (const row of rows) {
469
- if (signal?.aborted) return
476
+ signal?.throwIfAborted()
470
477
  if (++innerCount % YIELD_INTERVAL === 0) {
471
478
  await yieldToEventLoop()
472
- if (signal?.aborted) return
479
+ signal?.throwIfAborted()
473
480
  }
474
481
  if (skipped < offset) {
475
482
  skipped++
@@ -521,10 +528,10 @@ function executeProject(plan, context) {
521
528
  let innerCount = 0
522
529
 
523
530
  for await (const row of child.rows()) {
524
- if (context.signal?.aborted) return
531
+ context.signal?.throwIfAborted()
525
532
  if (++innerCount % YIELD_INTERVAL === 0) {
526
533
  await yieldToEventLoop()
527
- if (context.signal?.aborted) return
534
+ context.signal?.throwIfAborted()
528
535
  }
529
536
  rowIndex++
530
537
  const currentRowIndex = rowIndex
@@ -614,10 +621,10 @@ function executeDistinct(plan, context) {
614
621
  let innerCount = 0
615
622
 
616
623
  for await (const row of child.rows()) {
617
- if (signal?.aborted) return
624
+ signal?.throwIfAborted()
618
625
  if (++innerCount % YIELD_INTERVAL === 0) {
619
626
  await yieldToEventLoop()
620
- if (signal?.aborted) return
627
+ signal?.throwIfAborted()
621
628
  }
622
629
  buffer.push(row)
623
630
 
@@ -701,10 +708,10 @@ function executeSetOperation(plan, context) {
701
708
  const seen = new Set()
702
709
  let count = 0
703
710
  for await (const row of left.rows()) {
704
- if (signal?.aborted) return
711
+ signal?.throwIfAborted()
705
712
  if (++count % YIELD_INTERVAL === 0) {
706
713
  await yieldToEventLoop()
707
- if (signal?.aborted) return
714
+ signal?.throwIfAborted()
708
715
  }
709
716
  const key = await stableRowKey(row)
710
717
  if (!seen.has(key)) {
@@ -713,10 +720,10 @@ function executeSetOperation(plan, context) {
713
720
  }
714
721
  }
715
722
  for await (const row of right.rows()) {
716
- if (signal?.aborted) return
723
+ signal?.throwIfAborted()
717
724
  if (++count % YIELD_INTERVAL === 0) {
718
725
  await yieldToEventLoop()
719
- if (signal?.aborted) return
726
+ signal?.throwIfAborted()
720
727
  }
721
728
  const key = await stableRowKey(row)
722
729
  if (!seen.has(key)) {
@@ -739,10 +746,10 @@ function executeSetOperation(plan, context) {
739
746
  const rightKeys = new Map()
740
747
  let tick = 0
741
748
  for await (const row of right.rows()) {
742
- if (signal?.aborted) return
749
+ signal?.throwIfAborted()
743
750
  if (++tick % YIELD_INTERVAL === 0) {
744
751
  await yieldToEventLoop()
745
- if (signal?.aborted) return
752
+ signal?.throwIfAborted()
746
753
  }
747
754
  const key = await stableRowKey(row)
748
755
  rightKeys.set(key, (rightKeys.get(key) ?? 0) + 1)
@@ -751,10 +758,10 @@ function executeSetOperation(plan, context) {
751
758
  if (plan.all) {
752
759
  // INTERSECT ALL: yield each left row that matches, consuming right counts
753
760
  for await (const row of left.rows()) {
754
- if (signal?.aborted) return
761
+ signal?.throwIfAborted()
755
762
  if (++tick % YIELD_INTERVAL === 0) {
756
763
  await yieldToEventLoop()
757
- if (signal?.aborted) return
764
+ signal?.throwIfAborted()
758
765
  }
759
766
  const key = await stableRowKey(row)
760
767
  const count = rightKeys.get(key)
@@ -767,10 +774,10 @@ function executeSetOperation(plan, context) {
767
774
  // INTERSECT: yield deduplicated rows present in both
768
775
  const seen = new Set()
769
776
  for await (const row of left.rows()) {
770
- if (signal?.aborted) return
777
+ signal?.throwIfAborted()
771
778
  if (++tick % YIELD_INTERVAL === 0) {
772
779
  await yieldToEventLoop()
773
- if (signal?.aborted) return
780
+ signal?.throwIfAborted()
774
781
  }
775
782
  const key = await stableRowKey(row)
776
783
  if (rightKeys.has(key) && !seen.has(key)) {
@@ -794,10 +801,10 @@ function executeSetOperation(plan, context) {
794
801
  const rightKeys = new Map()
795
802
  let tick = 0
796
803
  for await (const row of right.rows()) {
797
- if (signal?.aborted) return
804
+ signal?.throwIfAborted()
798
805
  if (++tick % YIELD_INTERVAL === 0) {
799
806
  await yieldToEventLoop()
800
- if (signal?.aborted) return
807
+ signal?.throwIfAborted()
801
808
  }
802
809
  const key = await stableRowKey(row)
803
810
  rightKeys.set(key, (rightKeys.get(key) ?? 0) + 1)
@@ -806,10 +813,10 @@ function executeSetOperation(plan, context) {
806
813
  if (plan.all) {
807
814
  // EXCEPT ALL: yield left rows, consuming right counts
808
815
  for await (const row of left.rows()) {
809
- if (signal?.aborted) return
816
+ signal?.throwIfAborted()
810
817
  if (++tick % YIELD_INTERVAL === 0) {
811
818
  await yieldToEventLoop()
812
- if (signal?.aborted) return
819
+ signal?.throwIfAborted()
813
820
  }
814
821
  const key = await stableRowKey(row)
815
822
  const count = rightKeys.get(key)
@@ -823,10 +830,10 @@ function executeSetOperation(plan, context) {
823
830
  // EXCEPT: yield deduplicated left rows not in right
824
831
  const seen = new Set()
825
832
  for await (const row of left.rows()) {
826
- if (signal?.aborted) return
833
+ signal?.throwIfAborted()
827
834
  if (++tick % YIELD_INTERVAL === 0) {
828
835
  await yieldToEventLoop()
829
- if (signal?.aborted) return
836
+ signal?.throwIfAborted()
830
837
  }
831
838
  const key = await stableRowKey(row)
832
839
  if (!rightKeys.has(key) && !seen.has(key)) {
@@ -56,7 +56,7 @@ export function executeNestedLoopJoin(plan, context) {
56
56
  /** @type {AsyncRow[]} */
57
57
  const innerRows = []
58
58
  for await (const row of inner.rows()) {
59
- if (context.signal?.aborted) return
59
+ context.signal?.throwIfAborted()
60
60
  innerRows.push(row)
61
61
  }
62
62
 
@@ -69,7 +69,7 @@ export function executeNestedLoopJoin(plan, context) {
69
69
 
70
70
  let innerCount = 0
71
71
  for await (const outerRow of outer.rows()) {
72
- if (context.signal?.aborted) return
72
+ context.signal?.throwIfAborted()
73
73
 
74
74
  if (!outerCols) {
75
75
  outerCols = outerRow.columns
@@ -80,7 +80,7 @@ export function executeNestedLoopJoin(plan, context) {
80
80
  for (const innerRow of innerRows) {
81
81
  if (++innerCount % YIELD_INTERVAL === 0) {
82
82
  await yieldToEventLoop()
83
- if (context.signal?.aborted) return
83
+ context.signal?.throwIfAborted()
84
84
  }
85
85
  const tempMerged = merge(outerRow, innerRow)
86
86
  const matches = await evaluateExpr({
@@ -101,7 +101,7 @@ export function executeNestedLoopJoin(plan, context) {
101
101
  }
102
102
  }
103
103
 
104
- if (context.signal?.aborted) return
104
+ context.signal?.throwIfAborted()
105
105
 
106
106
  // Unmatched inner rows for outer joins on the buffered side
107
107
  if (matchedInnerRows) {
@@ -134,7 +134,7 @@ function executeLateralJoin(plan, context) {
134
134
  const rightTable = plan.rightAlias
135
135
 
136
136
  for await (const leftRow of left.rows()) {
137
- if (context.signal?.aborted) return
137
+ context.signal?.throwIfAborted()
138
138
 
139
139
  // When nested inside a correlated subquery, preserve the enclosing
140
140
  // outer row so UNNEST args can reference its columns (e.g. o.arr).
@@ -146,7 +146,7 @@ function executeLateralJoin(plan, context) {
146
146
 
147
147
  let hasMatch = false
148
148
  for await (const rightRow of right.rows()) {
149
- if (context.signal?.aborted) return
149
+ context.signal?.throwIfAborted()
150
150
  const merged = mergeRows(leftRow, rightRow, leftTable, rightTable)
151
151
  const matches = plan.condition === undefined
152
152
  ? true
@@ -199,10 +199,10 @@ export function executePositionalJoin(plan, context) {
199
199
  while (true) {
200
200
  const [leftResult, rightResult] = await Promise.all([leftRows.next(), rightRows.next()])
201
201
  if (leftResult.done && rightResult.done) return
202
- if (signal?.aborted) return
202
+ signal?.throwIfAborted()
203
203
  if (++tick % YIELD_INTERVAL === 0) {
204
204
  await yieldToEventLoop()
205
- if (signal?.aborted) return
205
+ signal?.throwIfAborted()
206
206
  }
207
207
  if (!leftResult.done && !leftCols.length) leftCols = leftResult.value.columns
208
208
  if (!rightResult.done && !rightCols.length) rightCols = rightResult.value.columns
@@ -266,10 +266,10 @@ export function executeHashJoin(plan, context) {
266
266
  let buildCols = []
267
267
  let innerCount = 0
268
268
  for await (const buildRow of build.rows()) {
269
- if (context.signal?.aborted) return
269
+ context.signal?.throwIfAborted()
270
270
  if (++innerCount % YIELD_INTERVAL === 0) {
271
271
  await yieldToEventLoop()
272
- if (context.signal?.aborted) return
272
+ context.signal?.throwIfAborted()
273
273
  }
274
274
  if (!buildCols.length) {
275
275
  buildCols = buildRow.columns
@@ -297,7 +297,7 @@ export function executeHashJoin(plan, context) {
297
297
 
298
298
  // Probe phase: stream the other side
299
299
  for await (const probeRow of probe.rows()) {
300
- if (context.signal?.aborted) return
300
+ context.signal?.throwIfAborted()
301
301
 
302
302
  if (!probeCols) {
303
303
  probeCols = probeRow.columns
@@ -314,7 +314,7 @@ export function executeHashJoin(plan, context) {
314
314
  for (const buildRow of candidates) {
315
315
  if (++innerCount % YIELD_INTERVAL === 0) {
316
316
  await yieldToEventLoop()
317
- if (context.signal?.aborted) return
317
+ context.signal?.throwIfAborted()
318
318
  }
319
319
  const merged = merge(probeRow, buildRow)
320
320
  if (residual) {
@@ -333,7 +333,7 @@ export function executeHashJoin(plan, context) {
333
333
  }
334
334
  }
335
335
 
336
- if (context.signal?.aborted) return
336
+ context.signal?.throwIfAborted()
337
337
 
338
338
  // Unmatched build rows for outer joins on the build side
339
339
  if (buildRows && matchedBuildRows) {
@@ -146,7 +146,7 @@ export function executeSort(plan, context) {
146
146
  /** @type {SortEntry[]} */
147
147
  let entries = []
148
148
  for await (const row of child.rows()) {
149
- if (context.signal?.aborted) return
149
+ context.signal?.throwIfAborted()
150
150
  entries.push({ row })
151
151
  if (entries.length >= bufferLimit) {
152
152
  // Sort keys are cached on the rows, so survivors of one truncation
@@ -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 }
@@ -409,8 +418,8 @@ async function accumulateChunk({ chunk, groupBy, specs, groups, needsRow, contex
409
418
 
410
419
  /**
411
420
  * Consumes the child rows into per-group accumulators, holding at most one
412
- * chunk of rows at a time. Returns undefined when aborted mid-collection so
413
- * callers end the row stream silently, matching the buffered path.
421
+ * chunk of rows at a time. Throws when aborted so partial accumulators are
422
+ * never finalized into results.
414
423
  *
415
424
  * @param {object} options
416
425
  * @param {QueryResults} options.child
@@ -418,7 +427,7 @@ async function accumulateChunk({ chunk, groupBy, specs, groups, needsRow, contex
418
427
  * @param {StreamingAggSpec[]} options.specs
419
428
  * @param {boolean} options.needsRow
420
429
  * @param {ExecuteContext} options.context
421
- * @returns {Promise<Map<unknown, StreamingGroup> | undefined>}
430
+ * @returns {Promise<Map<unknown, StreamingGroup>>}
422
431
  */
423
432
  async function accumulateGroups({ child, groupBy, specs, needsRow, context }) {
424
433
  /** @type {Map<unknown, StreamingGroup>} */
@@ -431,14 +440,11 @@ async function accumulateGroups({ child, groupBy, specs, needsRow, context }) {
431
440
  await accumulateChunk({ chunk, groupBy, specs, groups, needsRow, context })
432
441
  chunk = []
433
442
  await yieldToEventLoop()
434
- if (context.signal?.aborted) return
443
+ context.signal?.throwIfAborted()
435
444
  }
436
445
  }
437
446
  if (chunk.length) {
438
447
  await accumulateChunk({ chunk, groupBy, specs, groups, needsRow, context })
439
- // An abort during the final partial chunk ends the stream silently,
440
- // consistent with the full-chunk check above
441
- if (context.signal?.aborted) return
442
448
  }
443
449
  context.signal?.throwIfAborted()
444
450
  return groups
@@ -531,7 +537,6 @@ export function streamingHashAggregateRows({ plan, streaming, child, context })
531
537
  const { specs, keyRefs, needsRow } = streaming
532
538
  return async function* () {
533
539
  const groups = await accumulateGroups({ child, groupBy: plan.groupBy, specs, needsRow, context })
534
- if (!groups) return
535
540
  const { orderBy, having } = plan
536
541
 
537
542
  // Without ORDER BY, groups finalize and yield one at a time so output
@@ -585,7 +590,6 @@ export function streamingScalarAggregateRows({ plan, streaming, child, context }
585
590
  const { specs, keyRefs, needsRow } = streaming
586
591
  return async function* () {
587
592
  const groups = await accumulateGroups({ child, groupBy: [], specs, needsRow, context })
588
- if (!groups) return
589
593
  /** @type {StreamingGroup} */
590
594
  const group = groups.get(true) ?? { firstRow: undefined, keyValues: [], accumulators: specs.map(spec => newAccumulator(spec.funcName, spec.node.distinct)) }
591
595
 
@@ -42,7 +42,7 @@ export function executeWindow(plan, context) {
42
42
  for await (const row of child.rows()) {
43
43
  if (++i % YIELD_INTERVAL === 0) {
44
44
  await yieldToEventLoop()
45
- if (context.signal?.aborted) return
45
+ context.signal?.throwIfAborted()
46
46
  }
47
47
  const cells = { ...row.cells }
48
48
  for (const w of plan.windows) {
@@ -69,7 +69,7 @@ export function executeWindow(plan, context) {
69
69
  for await (const row of child.rows()) {
70
70
  if (++collectCount % YIELD_INTERVAL === 0) {
71
71
  await yieldToEventLoop()
72
- if (context.signal?.aborted) return
72
+ context.signal?.throwIfAborted()
73
73
  }
74
74
  rows.push(row)
75
75
  }
@@ -81,14 +81,14 @@ export function executeWindow(plan, context) {
81
81
 
82
82
  for (let w = 0; w < plan.windows.length; w++) {
83
83
  await computeWindow(plan.windows[w], rows, windowValues[w], context)
84
- if (context.signal?.aborted) return
84
+ context.signal?.throwIfAborted()
85
85
  }
86
86
 
87
87
  let emitCount = 0
88
88
  for (let i = 0; i < rows.length; i++) {
89
89
  if (++emitCount % YIELD_INTERVAL === 0) {
90
90
  await yieldToEventLoop()
91
- if (context.signal?.aborted) return
91
+ context.signal?.throwIfAborted()
92
92
  }
93
93
  const row = rows[i]
94
94
  const cells = { ...row.cells }
@@ -122,7 +122,7 @@ async function computeWindow(spec, rows, output, context) {
122
122
  for (let chunkStart = 0; chunkStart < rows.length; chunkStart += YIELD_INTERVAL) {
123
123
  if (chunkStart > 0) {
124
124
  await yieldToEventLoop()
125
- if (context.signal?.aborted) return
125
+ context.signal?.throwIfAborted()
126
126
  }
127
127
  const chunkEnd = Math.min(chunkStart + YIELD_INTERVAL, rows.length)
128
128
  const chunkKeys = await Promise.all(
@@ -142,7 +142,7 @@ async function computeWindow(spec, rows, output, context) {
142
142
  }
143
143
 
144
144
  for (const bucket of partitions.values()) {
145
- if (context.signal?.aborted) return
145
+ context.signal?.throwIfAborted()
146
146
 
147
147
  // Order within the partition. Empty ORDER BY → input order.
148
148
  /** @type {number[]} */
@@ -153,7 +153,7 @@ async function computeWindow(spec, rows, output, context) {
153
153
  for (let chunkStart = 0; chunkStart < bucket.length; chunkStart += YIELD_INTERVAL) {
154
154
  if (chunkStart > 0) {
155
155
  await yieldToEventLoop()
156
- if (context.signal?.aborted) return
156
+ context.signal?.throwIfAborted()
157
157
  }
158
158
  const chunkEnd = Math.min(chunkStart + YIELD_INTERVAL, bucket.length)
159
159
  const chunkValues = await Promise.all(
@@ -205,7 +205,7 @@ async function applyWindowFunction(spec, ordered, rows, output, context) {
205
205
  for (let k = 0; k < ordered.length; k++) {
206
206
  if (++tick % YIELD_INTERVAL === 0) {
207
207
  await yieldToEventLoop()
208
- if (context.signal?.aborted) return
208
+ context.signal?.throwIfAborted()
209
209
  }
210
210
  const idx = ordered[k]
211
211
  const row = rows[idx]
@@ -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
package/src/index.d.ts CHANGED
@@ -28,7 +28,7 @@ export type {
28
28
  * @param options.tables - source data as a list of objects or an AsyncDataSource
29
29
  * @param options.query - SQL query string
30
30
  * @param options.functions - user-defined functions available in the SQL context
31
- * @param options.signal - AbortSignal to cancel the query
31
+ * @param options.signal - AbortSignal to cancel the query; an aborted query rejects with the signal's reason
32
32
  * @returns async generator yielding rows matching the query
33
33
  */
34
34
  export function executeSql(options: ExecuteSqlOptions): QueryResults
@@ -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
@@ -166,6 +166,7 @@ export type StringFunc =
166
166
  | 'LOWER'
167
167
  | 'CONCAT'
168
168
  | 'LENGTH'
169
+ | 'OCTET_LENGTH'
169
170
  | 'SUBSTRING'
170
171
  | 'SUBSTR'
171
172
  | 'TRIM'
@@ -175,6 +176,8 @@ export type StringFunc =
175
176
  | 'INSTR'
176
177
  | 'POSITION'
177
178
  | 'STRPOS'
179
+ | 'SPLIT_PART'
180
+ | 'STRING_SPLIT'
178
181
 
179
182
  export type SpatialFunc =
180
183
  | '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) {