squirreling 0.16.10 → 0.16.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.16.10",
3
+ "version": "0.16.12",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -5,6 +5,7 @@ import { finalizeAccumulator, newAccumulator, updateAccumulator } from './accumu
5
5
  import { executePlan, executeScan, selectColumnNames } from './execute.js'
6
6
  import { foldEvaluatedRows } from './fold.js'
7
7
  import { normalizeScanColumnResult } from './scanColumn.js'
8
+ import { scanFilter } from './scanFilter.js'
8
9
  import { sortEntriesByTerms } from './sort.js'
9
10
  import { planStreamingAggregates, streamingHashAggregateRows, streamingScalarAggregateRows } from './streamingAggregate.js'
10
11
  import { keyify } from './utils.js'
@@ -289,10 +290,12 @@ function tryColumnScanAggregate(plan, { tables, signal }) {
289
290
  // Child must be a direct table scan
290
291
  if (plan.child.type !== 'Scan') return
291
292
  const scanNode = plan.child
292
- const { limit, offset, where } = scanNode.hints
293
+ const { limit, offset } = scanNode.hints
293
294
 
294
295
  const table = tables[scanNode.table]
295
296
  if (!table?.scanColumn) return
297
+ const { where, residual } = scanFilter(scanNode, dataSourceColumns(table))
298
+ if (residual) return
296
299
 
297
300
  // COUNT(*) needs a physical column whose filtered chunk lengths can be
298
301
  // counted. Prefer a predicate/projection column, then any table column.
@@ -14,6 +14,7 @@ import { distinctBatches, filterBatches, limitBatches, projectExpressionBatches
14
14
  import { executeHashJoin, executeNestedLoopJoin, executePositionalJoin } from './join.js'
15
15
  import { referencesRowScope } from './rowScope.js'
16
16
  import { normalizeScanColumnResult } from './scanColumn.js'
17
+ import { scanFilter } from './scanFilter.js'
17
18
  import { executeSort } from './sort.js'
18
19
  import { executeSemiJoin } from './semiJoin.js'
19
20
  import { addBounds, minBounds, stableRowKey } from './utils.js'
@@ -317,35 +318,51 @@ export function executeScan(plan, context, existingColumnResult) {
317
318
  const { tables, signal } = context
318
319
  const table = validateTable({ ...plan, tables })
319
320
  validateScan({ ...plan, tables })
320
- const hasLimitOffset = plan.hints.limit !== undefined || plan.hints.offset // 0 offset is noop
321
+ const { where, residual } = scanFilter(plan, dataSourceColumns(table))
322
+ const hints = { ...plan.hints, where }
323
+ if (residual) {
324
+ // Keep range and Top-K outside the scan until the engine residual runs.
325
+ delete hints.limit
326
+ delete hints.offset
327
+ }
328
+ const hasLimitOffset = hints.limit !== undefined || hints.offset // 0 offset is noop
321
329
  const scanContext = { ...context, scope: [plan.alias ?? plan.table] }
322
330
 
323
331
  if (!existingColumnResult && table.prepareScan && table.schema) {
324
- const prepared = table.prepareScan(scanRequest(plan, table.schema, scanContext))
325
- return executePreparedScan({ plan, prepared, context: scanContext, table })
332
+ // Derive column demands from the original predicate, including fields
333
+ // needed by engine residuals, but send only the source-local predicate.
334
+ const request = scanRequest(plan, table.schema, scanContext)
335
+ request.filter = where
336
+ if (residual) {
337
+ delete request.limit
338
+ delete request.offset
339
+ delete request.topK
340
+ }
341
+ const prepared = table.prepareScan(request)
342
+ return finish(executePreparedScan({ plan: { ...plan, hints }, prepared, context: scanContext, table }))
326
343
  }
327
344
 
328
345
  // Fast path: single column scan. As with scan(), hints the source did not
329
346
  // apply are handled by the engine over the returned column values.
330
- const scanColumnOptions = plan.hints.columns?.length === 1
331
- ? plan.hints.where
347
+ const scanColumnOptions = hints.columns?.length === 1
348
+ ? hints.where
332
349
  // Do not push a filtered range until WHERE is known to be applied: an
333
350
  // older source may ignore WHERE but eagerly apply LIMIT/OFFSET.
334
- ? { column: plan.hints.columns[0], where: plan.hints.where, signal }
335
- : { column: plan.hints.columns[0], ...plan.hints, signal }
351
+ ? { column: hints.columns[0], where: hints.where, signal }
352
+ : { column: hints.columns[0], ...hints, signal }
336
353
  : undefined
337
354
  const columnResult = existingColumnResult ?? (table.scanColumn && scanColumnOptions
338
355
  ? normalizeScanColumnResult(table.scanColumn(scanColumnOptions), scanColumnOptions)
339
356
  : undefined)
340
357
  if (columnResult && scanColumnOptions) {
341
- const column = plan.hints.columns[0]
342
- const scanRows = computeScanRows(table.numRows, plan.hints.limit, plan.hints.offset)
358
+ const column = hints.columns[0]
359
+ const scanRows = computeScanRows(table.numRows, hints.limit, hints.offset)
343
360
  const columns = [column]
344
- const appliedLimitOffset = plan.hints.where
361
+ const appliedLimitOffset = hints.where
345
362
  ? false
346
363
  : columnResult.appliedLimitOffset
347
- const residualFilter = plan.hints.where && !columnResult.appliedWhere
348
- ? compileBatchExpression(plan.hints.where, columns)
364
+ const residualFilter = hints.where && !columnResult.appliedWhere
365
+ ? compileBatchExpression(hints.where, columns)
349
366
  : undefined
350
367
 
351
368
  /** @returns {AsyncIterable<AsyncBatch>} */
@@ -353,28 +370,28 @@ export function executeScan(plan, context, existingColumnResult) {
353
370
  /** @type {AsyncIterable<AsyncBatch>} */
354
371
  let batches = columnBatches(columnResult.chunks(), signal)
355
372
  if (residualFilter) {
356
- const targetRows = plan.hints.limit === undefined
373
+ const targetRows = hints.limit === undefined
357
374
  ? undefined
358
- : plan.hints.limit + (plan.hints.offset ?? 0)
375
+ : hints.limit + (hints.offset ?? 0)
359
376
  batches = filterBatches(batches, residualFilter, signal, targetRows)
360
377
  }
361
378
  if (!appliedLimitOffset && hasLimitOffset) {
362
- batches = limitBatches(batches, plan.hints.limit, plan.hints.offset, signal)
379
+ batches = limitBatches(batches, hints.limit, hints.offset, signal)
363
380
  }
364
381
  return batches
365
382
  }
366
383
 
367
- const canUseBatches = !plan.hints.where || columnResult.appliedWhere || residualFilter !== undefined
384
+ const canUseBatches = !hints.where || columnResult.appliedWhere || residualFilter !== undefined
368
385
  if (canUseBatches) {
369
- return batchResult({
386
+ return finish(batchResult({
370
387
  columns,
371
- numRows: plan.hints.where ? undefined : scanRows,
388
+ numRows: hints.where ? undefined : scanRows,
372
389
  maxRows: scanRows,
373
390
  batches: makeBatches,
374
391
  signal,
375
- })
392
+ }))
376
393
  }
377
- return {
394
+ return finish({
378
395
  columns,
379
396
  numRows: undefined,
380
397
  maxRows: scanRows,
@@ -395,10 +412,10 @@ export function executeScan(plan, context, existingColumnResult) {
395
412
  }
396
413
  })()
397
414
 
398
- result = filterRows(result, plan.hints.where, scanContext, plan.hints.limit)
415
+ result = filterRows(result, hints.where, scanContext, hints.limit)
399
416
  // Filtered LIMIT/OFFSET was intentionally not passed to scanColumn.
400
417
  if (!appliedLimitOffset && hasLimitOffset) {
401
- result = limitRows(result, plan.hints.limit, plan.hints.offset, signal)
418
+ result = limitRows(result, hints.limit, hints.offset, signal)
402
419
  }
403
420
 
404
421
  yield* result
@@ -406,7 +423,7 @@ export function executeScan(plan, context, existingColumnResult) {
406
423
  // the abort so a truncated scan is not mistaken for a complete one
407
424
  signal?.throwIfAborted()
408
425
  },
409
- }
426
+ })
410
427
  }
411
428
 
412
429
  if (!table.scan) {
@@ -414,30 +431,30 @@ export function executeScan(plan, context, existingColumnResult) {
414
431
  }
415
432
 
416
433
  // do the scan
417
- const scanResult = table.scan({ ...plan.hints, signal })
434
+ const scanResult = table.scan({ ...hints, signal })
418
435
  const { appliedWhere, appliedLimitOffset } = scanResult
419
436
 
420
437
  // Applied limit/offset without applied where is invalid
421
- if (!appliedWhere && appliedLimitOffset && plan.hints.where && hasLimitOffset) {
438
+ if (!appliedWhere && appliedLimitOffset && hints.where && hasLimitOffset) {
422
439
  throw new Error(`Data source "${plan.table}" applied limit/offset without applying where`)
423
440
  }
424
441
 
425
- const scanRows = computeScanRows(table.numRows, plan.hints.limit, plan.hints.offset)
426
- return {
427
- columns: plan.hints.columns ?? dataSourceColumns(table),
428
- numRows: !plan.hints.where ? scanRows : undefined,
442
+ const scanRows = computeScanRows(table.numRows, hints.limit, hints.offset)
443
+ return finish({
444
+ columns: hints.columns ?? dataSourceColumns(table),
445
+ numRows: !hints.where ? scanRows : undefined,
429
446
  maxRows: scanRows,
430
447
  async *rows() {
431
448
  let result = scanResult.rows()
432
449
 
433
450
  // Apply WHERE if data source did not
434
- if (!appliedWhere && plan.hints.where) {
435
- result = filterRows(result, plan.hints.where, scanContext, plan.hints.limit)
451
+ if (!appliedWhere && hints.where) {
452
+ result = filterRows(result, hints.where, scanContext, hints.limit)
436
453
  }
437
454
 
438
455
  // Apply LIMIT/OFFSET if data source did not
439
456
  if (!appliedLimitOffset && hasLimitOffset) {
440
- result = limitRows(result, plan.hints.limit, plan.hints.offset, signal)
457
+ result = limitRows(result, hints.limit, hints.offset, signal)
441
458
  }
442
459
 
443
460
  yield* result
@@ -446,6 +463,24 @@ export function executeScan(plan, context, existingColumnResult) {
446
463
  // the abort so a truncated scan is not mistaken for a complete one
447
464
  signal?.throwIfAborted()
448
465
  },
466
+ })
467
+
468
+ /**
469
+ * Apply engine-only predicates and ranges after any source execution path.
470
+ * @param {QueryResults} source
471
+ * @returns {QueryResults}
472
+ */
473
+ function finish(source) {
474
+ if (!residual) return source
475
+ const { limit, offset } = plan.hints
476
+ return {
477
+ columns: source.columns,
478
+ maxRows: computeScanRows(source.maxRows, limit, offset),
479
+ async *rows() {
480
+ const filtered = filterRows(source.rows(), residual, scanContext, limit)
481
+ yield* limitRows(filtered, limit, offset, signal)
482
+ },
483
+ }
449
484
  }
450
485
  }
451
486
 
@@ -60,7 +60,7 @@ export function executeNestedLoopJoin(plan, context) {
60
60
  innerRows.push(row)
61
61
  }
62
62
 
63
- const innerCols = innerRows.length ? innerRows[0].columns : []
63
+ const innerCols = innerRows.length ? innerRows[0].columns : inner.columns
64
64
 
65
65
  /** @type {string[] | undefined} */
66
66
  let outerCols = undefined
@@ -107,7 +107,7 @@ export function executeNestedLoopJoin(plan, context) {
107
107
  if (matchedInnerRows) {
108
108
  for (const innerRow of innerRows) {
109
109
  if (!matchedInnerRows.has(innerRow)) {
110
- yield merge(createNullRow(outerCols ?? []), innerRow)
110
+ yield merge(createNullRow(outerCols ?? outer.columns), innerRow)
111
111
  }
112
112
  }
113
113
  }
@@ -329,7 +329,7 @@ export function executeHashJoin(plan, context) {
329
329
  }
330
330
 
331
331
  if (!matched && probeOuter) {
332
- yield merge(probeRow, createNullRow(buildCols))
332
+ yield merge(probeRow, createNullRow(buildCols.length ? buildCols : build.columns))
333
333
  }
334
334
  }
335
335
 
@@ -339,7 +339,7 @@ export function executeHashJoin(plan, context) {
339
339
  if (buildRows && matchedBuildRows) {
340
340
  for (const buildRow of buildRows) {
341
341
  if (!matchedBuildRows.has(buildRow)) {
342
- yield merge(createNullRow(probeCols ?? []), buildRow)
342
+ yield merge(createNullRow(probeCols ?? probe.columns), buildRow)
343
343
  }
344
344
  }
345
345
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @import { ExprNode } from '../types.js'
3
+ * @import { ScanNode } from '../plan/types.js'
4
+ */
5
+
6
+ /**
7
+ * Translate local conjuncts into physical column names. Keep subqueries and
8
+ * references outside this scan in the engine. Match scope names exactly, as
9
+ * expression evaluation does, including quoted identifiers and case.
10
+ *
11
+ * @param {ScanNode} plan
12
+ * @param {string[]} columns
13
+ * @returns {{ where?: ExprNode, residual?: ExprNode }}
14
+ */
15
+ export function scanFilter(plan, columns) {
16
+ const scope = plan.alias ?? plan.table
17
+
18
+ /**
19
+ * @param {ExprNode} node
20
+ * @returns {{ where?: ExprNode, residual?: ExprNode }}
21
+ */
22
+ function split(node) {
23
+ if (!node) return {}
24
+ if (node.type === 'binary' && node.op === 'AND') {
25
+ const left = split(node.left)
26
+ const right = split(node.right)
27
+ return {
28
+ where: combine(left.where, right.where),
29
+ residual: combine(left.residual, right.residual),
30
+ }
31
+ }
32
+ let safe = true
33
+ /**
34
+ * @param {ExprNode} expr
35
+ * @returns {ExprNode}
36
+ */
37
+ function rewrite(expr) {
38
+ if (!expr) return expr
39
+ if (expr.type === 'identifier') {
40
+ if (expr.prefix && expr.prefix !== scope || !columns.includes(expr.name) ||
41
+ expr.prefix && columns.includes(`${expr.prefix}.${expr.name}`)) {
42
+ safe = false
43
+ return expr
44
+ }
45
+ if (!expr.prefix) return expr
46
+ const bare = { ...expr }
47
+ delete bare.prefix
48
+ return bare
49
+ }
50
+ if (expr.type === 'literal' || expr.type === 'interval') return expr
51
+ if (expr.type === 'binary') return { ...expr, left: rewrite(expr.left), right: rewrite(expr.right) }
52
+ if (expr.type === 'unary') return { ...expr, argument: rewrite(expr.argument) }
53
+ if (expr.type === 'cast') return { ...expr, expr: rewrite(expr.expr) }
54
+ if (expr.type === 'function') return { ...expr, args: expr.args.map(rewrite), ...expr.filter ? { filter: rewrite(expr.filter) } : {} }
55
+ if (expr.type === 'in valuelist') return { ...expr, expr: rewrite(expr.expr), values: expr.values.map(rewrite) }
56
+ if (expr.type === 'subscript') return { ...expr, expr: rewrite(expr.expr), index: rewrite(expr.index) }
57
+ if (expr.type === 'case') return {
58
+ ...expr,
59
+ caseExpr: rewrite(expr.caseExpr),
60
+ whenClauses: expr.whenClauses.map(w => ({ ...w, condition: rewrite(w.condition), result: rewrite(w.result) })),
61
+ elseResult: rewrite(expr.elseResult),
62
+ }
63
+ safe = false
64
+ return expr
65
+ }
66
+ const where = rewrite(node)
67
+ return safe ? { where } : { residual: node }
68
+ }
69
+ return split(plan.hints.where)
70
+ }
71
+
72
+ /**
73
+ * @param {ExprNode} left
74
+ * @param {ExprNode} right
75
+ * @returns {ExprNode}
76
+ */
77
+ function combine(left, right) {
78
+ if (!left) return right
79
+ if (!right) return left
80
+ return { type: 'binary', op: 'AND', left, right, positionStart: left.positionStart, positionEnd: right.positionEnd }
81
+ }
@@ -4,6 +4,7 @@ import { sqlEquals } from './utils.js'
4
4
  import { yieldToEventLoop } from './yield.js'
5
5
 
6
6
  /**
7
+ * @import { KeyTrie } from '../internalTypes.js'
7
8
  * @import { ExecuteContext, QueryResults, SqlPrimitive } from '../types.js'
8
9
  * @import { SemiJoinNode } from '../plan/types.js'
9
10
  */
@@ -17,6 +18,7 @@ import { yieldToEventLoop } from './yield.js'
17
18
  * @returns {QueryResults}
18
19
  */
19
20
  export function executeSemiJoin(plan, context) {
21
+ if (plan.keys) return executeExistsJoin(plan, context)
20
22
  const left = executePlan({ plan: plan.left, context })
21
23
  return {
22
24
  columns: left.columns,
@@ -75,3 +77,73 @@ function contains(values, value) {
75
77
  }
76
78
  return false
77
79
  }
80
+
81
+ /**
82
+ * Builds a tuple membership trie once. Type partitions preserve the evaluator's
83
+ * coercive equality without making same-type primitive lookups linear.
84
+ *
85
+ * @param {SemiJoinNode} plan
86
+ * @param {ExecuteContext} context
87
+ * @returns {QueryResults}
88
+ */
89
+ function executeExistsJoin(plan, context) {
90
+ const left = executePlan({ plan: plan.left, context })
91
+ return {
92
+ columns: left.columns,
93
+ maxRows: left.maxRows,
94
+ async *rows() {
95
+ /** @type {KeyTrie} */
96
+ const root = { groups: new Map() }
97
+ let built = false
98
+ let count = 0
99
+ for await (const row of left.rows()) {
100
+ context.signal?.throwIfAborted()
101
+ if (++count % 4000 === 0) await yieldToEventLoop()
102
+ if (!built) {
103
+ for await (const inner of executePlan({ plan: plan.right, context }).rows()) {
104
+ context.signal?.throwIfAborted()
105
+ if (++count % 4000 === 0) await yieldToEventLoop()
106
+ const values = await Promise.all(inner.columns.map(col => inner.cells[col]()))
107
+ if (values.some(value => value == null)) continue
108
+ let node = root
109
+ for (const value of values) {
110
+ const type = typeof value
111
+ let group = node.groups.get(type)
112
+ if (!group) node.groups.set(type, group = new Map())
113
+ let child = group.get(value)
114
+ if (!child) group.set(value, child = { groups: new Map() })
115
+ node = child
116
+ }
117
+ }
118
+ built = true
119
+ }
120
+ const values = await Promise.all((plan.keys ?? []).map(node => evaluateExpr({ node, row, context })))
121
+ const matched = !values.some(value => value == null) && matchesKeys(root, values, 0)
122
+ if (matched !== plan.not) yield row
123
+ }
124
+ },
125
+ }
126
+ }
127
+
128
+ /**
129
+ * @param {KeyTrie} node
130
+ * @param {SqlPrimitive[]} values
131
+ * @param {number} index
132
+ * @returns {boolean}
133
+ */
134
+ function matchesKeys(node, values, index) {
135
+ if (index === values.length) return true
136
+ const value = values[index]
137
+ const type = typeof value
138
+ if (type !== 'object' && !Number.isNaN(value)) {
139
+ const child = node.groups.get(type)?.get(value)
140
+ if (child && matchesKeys(child, values, index + 1)) return true
141
+ }
142
+ for (const [valueType, group] of node.groups) {
143
+ if (valueType === type && type !== 'object') continue
144
+ for (const [candidate, child] of group) {
145
+ if (sqlEquals(value, candidate) && matchesKeys(child, values, index + 1)) return true
146
+ }
147
+ }
148
+ return false
149
+ }
@@ -713,7 +713,11 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
713
713
  // IN with subqueries, same three-valued logic as the value-list form
714
714
  if (node.type === 'in') {
715
715
  const exprVal = await evaluateExpr({ node: node.expr, row, rowIndex, rows, context })
716
- const subResult = executeStatement({ query: node.subquery, context })
716
+ const outerScope = context.scope
717
+ const subContext = outerScope
718
+ ? { ...context, outerRow: row, outerAliases: new Set(outerScope) }
719
+ : context
720
+ const subResult = executeStatement({ query: node.subquery, context: subContext, outerScope })
717
721
  let sawNull = false
718
722
  let innerCount = 0
719
723
  for await (const resRow of subResult.rows()) {
@@ -16,7 +16,22 @@ import { ArgValueError } from '../validation/executionErrors.js'
16
16
  * @returns {SqlPrimitive}
17
17
  */
18
18
  export function evaluateRegexpFunc({ funcName, node, args, rowIndex }) {
19
- if (funcName === 'REGEXP_SUBSTR' || funcName === 'REGEXP_EXTRACT') {
19
+ if (funcName === 'REGEXP_EXTRACT') {
20
+ const [str, pattern] = args
21
+ if (str == null || pattern == null || args.length >= 3 && args[2] == null) return null
22
+ const group = args.length >= 3 ? Number(args[2]) : 0
23
+ if (!Number.isInteger(group) || group < 0) {
24
+ throw new ArgValueError({
25
+ ...node,
26
+ message: `group must be a non-negative integer, got ${args[2]}`,
27
+ rowIndex,
28
+ })
29
+ }
30
+ const regex = compileRegexp(node, String(pattern), '', rowIndex)
31
+ return regex.exec(String(str))?.[group] ?? null
32
+ }
33
+
34
+ if (funcName === 'REGEXP_SUBSTR') {
20
35
  const str = args[0]
21
36
  const pattern = args[1]
22
37
  if (str == null || pattern == null) return null
@@ -51,17 +66,7 @@ export function evaluateRegexpFunc({ funcName, node, args, rowIndex }) {
51
66
  }
52
67
  }
53
68
 
54
- // Create regex
55
- let regex
56
- try {
57
- regex = new RegExp(patternStr, 'g')
58
- } catch (/** @type {any} */ error) {
59
- throw new ArgValueError({
60
- ...node,
61
- message: `invalid regex pattern: ${error.message}`,
62
- rowIndex,
63
- })
64
- }
69
+ const regex = compileRegexp(node, patternStr, 'g', rowIndex)
65
70
 
66
71
  // Search from position (convert to 0-based)
67
72
  const searchStr = strVal.substring(position - 1)
@@ -74,6 +79,8 @@ export function evaluateRegexpFunc({ funcName, node, args, rowIndex }) {
74
79
  if (count === occurrence) {
75
80
  return match[0]
76
81
  }
82
+ // RegExp.exec does not advance after an empty match.
83
+ if (match[0] === '') regex.lastIndex++
77
84
  }
78
85
 
79
86
  return null
@@ -120,17 +127,7 @@ export function evaluateRegexpFunc({ funcName, node, args, rowIndex }) {
120
127
  }
121
128
  }
122
129
 
123
- // Create regex
124
- let regex
125
- try {
126
- regex = new RegExp(patternStr, 'g')
127
- } catch (/** @type {any} */ error) {
128
- throw new ArgValueError({
129
- ...node,
130
- message: `invalid regex pattern: ${error.message}`,
131
- rowIndex,
132
- })
133
- }
130
+ const regex = compileRegexp(node, patternStr, 'g', rowIndex)
134
131
 
135
132
  // If position > 1, preserve the prefix
136
133
  const prefix = strVal.substring(0, position - 1)
@@ -174,15 +171,7 @@ export function evaluateRegexpLike({ node, args, rowIndex, cache }) {
174
171
  const patternString = String(pattern)
175
172
  let regex = cache?.pattern === patternString ? cache.regex : undefined
176
173
  if (!regex) {
177
- try {
178
- regex = new RegExp(patternString)
179
- } catch (/** @type {any} */ error) {
180
- throw new ArgValueError({
181
- ...node,
182
- message: `invalid regex pattern: ${error.message}`,
183
- rowIndex,
184
- })
185
- }
174
+ regex = compileRegexp(node, patternString, '', rowIndex)
186
175
  if (cache) {
187
176
  cache.pattern = patternString
188
177
  cache.regex = regex
@@ -194,3 +183,24 @@ export function evaluateRegexpLike({ node, args, rowIndex, cache }) {
194
183
  // '[object Object]', making REGEXP_LIKE a silent never-match on them.
195
184
  return regex.test(typeof string === 'object' ? stringify(string) : String(string))
196
185
  }
186
+
187
+ /**
188
+ * Compile a regex and report invalid patterns as SQL argument errors.
189
+ *
190
+ * @param {FunctionNode} node
191
+ * @param {string} pattern
192
+ * @param {string} flags
193
+ * @param {number} [rowIndex]
194
+ * @returns {RegExp}
195
+ */
196
+ function compileRegexp(node, pattern, flags, rowIndex) {
197
+ try {
198
+ return new RegExp(pattern, flags)
199
+ } catch (/** @type {any} */ error) {
200
+ throw new ArgValueError({
201
+ ...node,
202
+ message: `invalid regex pattern: ${error.message}`,
203
+ rowIndex,
204
+ })
205
+ }
206
+ }
@@ -1,5 +1,9 @@
1
1
  import type { ColumnReadRequest, ColumnResult, ColumnVector, SqlPrimitive } from './types.js'
2
2
 
3
+ export interface KeyTrie {
4
+ groups: Map<string, Map<SqlPrimitive, KeyTrie>>
5
+ }
6
+
3
7
  export interface CompiledBatchExpression {
4
8
  evaluate(context: ColumnReadRequest): ColumnResult
5
9
  }
package/src/plan/plan.js CHANGED
@@ -8,7 +8,7 @@ import { validateNoIdentifiers, validateScan, validateTableRefs } from '../valid
8
8
  import { collectColumnsFromExpr, collectScopeColumns, extractColumns, fromAlias, inferSelectSourceColumns, inferStatementColumns, statementScope, tableFunctionColumnNames } from './columns.js'
9
9
 
10
10
  /**
11
- * @import { AsyncDataSource, DerivedColumn, ExprNode, FromFunction, IdentifierNode, JoinClause, OrderByItem, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement, SetOperationStatement, Statement, WindowFunctionNode } from '../types.js'
11
+ * @import { AsyncDataSource, DerivedColumn, ExprNode, FromFunction, FromSubquery, FromTable, IdentifierNode, JoinClause, OrderByItem, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement, SetOperationStatement, Statement, WindowFunctionNode } from '../types.js'
12
12
  * @import { HashJoinNode, QueryPlan, SemiJoinNode, TableFunctionNode, WindowSpec } from './types.js'
13
13
  */
14
14
 
@@ -243,6 +243,20 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
243
243
  for (const conjunct of conjuncts) {
244
244
  const not = conjunct.type === 'unary' && conjunct.op === 'NOT'
245
245
  const expr = not ? conjunct.argument : conjunct
246
+ if (expr.type === 'exists' || expr.type === 'not exists') {
247
+ const keys = decorrelateExists(expr.subquery, scopeAliases, tables, cteColumns)
248
+ if (keys) {
249
+ const right = planStatement({ stmt: keys.query, tables, ctePlans, cteColumns })
250
+ semiJoins.push({
251
+ type: 'SemiJoin',
252
+ expr: keys.left[0],
253
+ keys: keys.left,
254
+ not: not !== (expr.type === 'not exists'),
255
+ right: { type: 'Subquery', child: right, scope: statementScope(keys.query) ?? [] },
256
+ })
257
+ continue
258
+ }
259
+ }
246
260
  if (expr.type !== 'in') {
247
261
  residual = andPredicate(residual, conjunct)
248
262
  continue
@@ -253,6 +267,23 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
253
267
  tables, cteColumns, outerAliases: new Set(scopeAliases),
254
268
  })
255
269
  if (correlated.length) {
270
+ // Positive correlated IN is EXISTS with an extra equality. NOT IN
271
+ // needs three-valued membership semantics and stays on the fallback.
272
+ // Unqualified operands must stay in the outer scope: moving them
273
+ // into the subquery could bind them to an inner column instead.
274
+ const sub = expr.subquery
275
+ const column = sub.type === 'select' && sub.columns.length === 1 ? sub.columns[0] : undefined
276
+ if (!not && sub.type === 'select' && column?.type === 'derived' &&
277
+ column.expr.type === 'identifier' && expr.expr.type === 'identifier' && expr.expr.prefix) {
278
+ /** @type {ExprNode} */
279
+ const equality = { type: 'binary', op: '=', left: column.expr, right: expr.expr, positionStart: expr.positionStart, positionEnd: expr.positionEnd }
280
+ const keys = decorrelateExists({ ...sub, where: andPredicate(sub.where, equality) }, scopeAliases, tables, cteColumns)
281
+ if (keys) {
282
+ const right = planStatement({ stmt: keys.query, tables, ctePlans, cteColumns })
283
+ semiJoins.push({ type: 'SemiJoin', expr: keys.left[0], keys: keys.left, not: false, right: { type: 'Subquery', child: right, scope: statementScope(keys.query) ?? [] } })
284
+ continue
285
+ }
286
+ }
256
287
  residual = andPredicate(residual, conjunct)
257
288
  continue
258
289
  }
@@ -279,7 +310,6 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
279
310
  /** @type {QueryPlan} */
280
311
  let plan = planFrom({ select, ctePlans, cteColumns, hints, tables, outerScope })
281
312
  pruneAggregateColumns(plan, subqueryNeeds)
282
- const directFrom = plan.type === 'Scan' && plan.hints === hints
283
313
 
284
314
  // Add JOINs
285
315
  if (select.joins.length) {
@@ -291,9 +321,8 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
291
321
 
292
322
  // Add WHERE filter when the scan didn't receive it
293
323
  let { where } = select
294
- if (directFrom && select.joins.length === 1 && !outerScope?.length &&
295
- !ctePlans?.has(select.joins[0].table?.toLowerCase())) {
296
- where = pushJoinPredicates(plan, where, tables)
324
+ if (select.joins.length && !outerScope?.length) {
325
+ where = pushJoinPredicates(plan, where, select, tables, cteColumns)
297
326
  }
298
327
  if (where && !isOwnScan) {
299
328
  plan = { type: 'Filter', condition: where, child: plan }
@@ -876,26 +905,34 @@ function collectConjuncts(node, out) {
876
905
  }
877
906
 
878
907
  /**
879
- * Push each table-local conjunct into fresh scans of a direct INNER
880
- * equijoin. Scan execution enforces hints a source cannot apply. Unresolved
881
- * and cross-table conjuncts remain residual predicates. Predicate evaluation
882
- * order and error short-circuiting are not guaranteed.
908
+ * Push local ON and WHERE conjuncts according to which side is preserved.
909
+ * Only recurse through this SELECT's join chain, never through shared CTEs
910
+ * or derived-table boundaries. Scan hints are copied rather than mutated.
883
911
  *
884
912
  * @param {QueryPlan} plan
885
913
  * @param {ExprNode | undefined} where
914
+ * @param {SelectStatement} select
886
915
  * @param {Record<string, AsyncDataSource>} [tables]
916
+ * @param {Map<string, string[]>} [cteColumns]
917
+ * @param {((aliases: string[]) => boolean)[]} [inherited] - proofs required by ancestor filters or inner joins
887
918
  * @returns {ExprNode | undefined}
888
919
  */
889
- function pushJoinPredicates(plan, where, tables) {
890
- if (!tables || plan.type !== 'HashJoin' || plan.joinType !== 'INNER' ||
891
- plan.left.type !== 'Scan' || plan.right.type !== 'Scan') return where
892
- const scans = [plan.left, plan.right]
893
- if (scans.some(scan => scan.hints.where || scan.hints.limit !== undefined || scan.hints.offset)) return where
894
- const aliases = scans.map(scan => scan.alias ?? scan.table)
895
- if (aliases[0] === aliases[1]) return where
896
- const columns = scans.map(scan => dataSourceColumns(tables[scan.table]))
897
- // A prefix that also names a struct column is not an unambiguous alias.
898
- if (columns.some(names => aliases.some(alias => names.includes(alias)))) return where
920
+ function pushJoinPredicates(plan, where, select, tables, cteColumns, inherited = []) {
921
+ if (!tables || plan.type !== 'HashJoin' && plan.type !== 'NestedLoopJoin' ||
922
+ select.from?.type === 'function' || select.joins.some(join => join.fromFunction)) return where
923
+ const last = select.joins[select.joins.length - 1]
924
+ const leftSelect = { ...select, joins: select.joins.slice(0, -1) }
925
+ const leftSources = [select.from, ...leftSelect.joins.map(joinSource)]
926
+ const rightSource = joinSource(last)
927
+ const sources = [...leftSources, rightSource]
928
+ const aliases = sources.map(source => fromAlias(source))
929
+ const columns = sources.map(source => source?.type === 'subquery'
930
+ ? inferStatementColumns({ stmt: source.query, tables, cteColumns })
931
+ : source?.type === 'table'
932
+ ? cteColumns?.get(source.table?.toLowerCase()) ?? dataSourceColumns(tables[source.table])
933
+ : [])
934
+ if (new Set(aliases).size !== aliases.length ||
935
+ columns.some(names => aliases.some(alias => names.includes(alias)))) return where
899
936
 
900
937
  /**
901
938
  * @param {ExprNode} expr
@@ -907,15 +944,14 @@ function pushJoinPredicates(plan, where, tables) {
907
944
  const matches = columns.map((names, i) => names.includes(expr.name) ? i : -1).filter(i => i >= 0)
908
945
  const index = expr.prefix ? aliases.indexOf(expr.prefix) : matches.length === 1 ? matches[0] : -1
909
946
  if (index < 0 || !columns[index].includes(expr.name)) return 'unsafe'
910
- return index === 0 ? 'left' : 'right'
947
+ return index < leftSources.length ? 'left' : 'right'
911
948
  }
912
949
 
913
- // Avoid changing the evaluation of unresolved or unqualified ON keys.
914
- if (plan.leftKeys.some(key => owner(key) !== 'left') || plan.rightKeys.some(key => owner(key) !== 'right')) return where
950
+ // Do not change the interpretation of ambiguous or misoriented hash keys.
951
+ if (plan.type === 'HashJoin' && (plan.leftKeys.some(key => owner(key) !== 'left') ||
952
+ plan.rightKeys.some(key => owner(key) !== 'right'))) return where
953
+
915
954
  /**
916
- * Resolve every reference in a conjunct independently. Subqueries and
917
- * windows have their own scope and must remain above the join.
918
- *
919
955
  * @param {ExprNode} expr
920
956
  * @returns {'left' | 'right' | 'literal' | 'unsafe'}
921
957
  */
@@ -942,51 +978,147 @@ function pushJoinPredicates(plan, where, tables) {
942
978
  return target
943
979
  }
944
980
 
981
+ /** @type {ExprNode[]} */
982
+ const whereConjuncts = []
983
+ if (where) collectConjuncts(where, whereConjuncts)
945
984
  /**
946
- * @param {ExprNode[]} conjuncts
947
- * @returns {Array<'left' | 'right' | 'residual'>}
985
+ * Capture this scope so unqualified references remain unambiguous when
986
+ * proofs are passed down a join chain with fewer visible sources.
987
+ * @param {ExprNode} expr
988
+ * @returns {(missing: string[]) => boolean}
948
989
  */
949
- function classify(conjuncts) {
950
- return conjuncts.map(function target(expr) {
951
- const side = expressionOwner(expr)
952
- return side === 'left' || side === 'right' ? side : 'residual'
990
+ function rejectionProof(expr) {
991
+ return missing => rejectsNulls(expr, column => {
992
+ if (column.type !== 'identifier') return false
993
+ const matches = columns.map((names, i) => names.includes(column.name) ? i : -1).filter(i => i >= 0)
994
+ const index = column.prefix ? aliases.indexOf(column.prefix) : matches.length === 1 ? matches[0] : -1
995
+ return index >= 0 && columns[index].includes(column.name) && missing.includes(aliases[index])
953
996
  })
954
997
  }
998
+ const proofs = [...inherited, ...whereConjuncts.map(rejectionProof)]
999
+ const rejectsLeft = proofs.some(proof => proof(aliases.slice(0, -1)))
1000
+ const rejectsRight = proofs.some(proof => proof(aliases.slice(-1)))
1001
+ if (plan.joinType === 'FULL') {
1002
+ if (rejectsLeft && rejectsRight) plan.joinType = 'INNER'
1003
+ else if (rejectsLeft) plan.joinType = 'LEFT'
1004
+ else if (rejectsRight) plan.joinType = 'RIGHT'
1005
+ } else if (plan.joinType === 'LEFT' && rejectsRight || plan.joinType === 'RIGHT' && rejectsLeft) {
1006
+ plan.joinType = 'INNER'
1007
+ }
955
1008
 
1009
+ const { joinType } = plan
1010
+ /** @type {ExprNode | undefined} */
1011
+ let leftWhere
1012
+ /** @type {ExprNode | undefined} */
1013
+ let rightWhere
956
1014
  /**
957
1015
  * @param {ExprNode[]} conjuncts
958
- * @param {Array<'left' | 'right' | 'residual'>} targets
1016
+ * @param {boolean} on
959
1017
  * @returns {ExprNode | undefined}
960
1018
  */
961
- function push(conjuncts, targets) {
1019
+ function push(conjuncts, on) {
962
1020
  /** @type {ExprNode | undefined} */
963
1021
  let residual
964
- for (let i = 0; i < conjuncts.length; i++) {
965
- const target = targets[i]
966
- if (target === 'residual') {
967
- residual = andPredicate(residual, conjuncts[i])
968
- } else {
969
- const scan = target === 'left' ? scans[0] : scans[1]
970
- scan.hints = { ...scan.hints, where: andPredicate(scan.hints.where, conjuncts[i]) }
971
- }
1022
+ for (const expr of conjuncts) {
1023
+ const side = expressionOwner(expr)
1024
+ const canLeft = joinType === 'INNER' || joinType === (on ? 'RIGHT' : 'LEFT')
1025
+ const canRight = joinType === 'INNER' || joinType === (on ? 'LEFT' : 'RIGHT')
1026
+ if (side === 'left' && canLeft) leftWhere = andPredicate(leftWhere, expr)
1027
+ else if (side === 'right' && canRight) rightWhere = andPredicate(rightWhere, expr)
1028
+ else residual = andPredicate(residual, expr)
972
1029
  }
973
1030
  return residual
974
1031
  }
975
1032
 
976
1033
  /** @type {ExprNode[]} */
977
1034
  const onConjuncts = []
978
- if (plan.residual) collectConjuncts(plan.residual, onConjuncts)
979
- const onTargets = classify(onConjuncts)
980
- const onResidual = push(onConjuncts, onTargets)
981
- if (onResidual) plan.residual = onResidual
982
- else delete plan.residual
1035
+ const condition = plan.type === 'HashJoin' ? plan.residual : plan.condition
1036
+ if (condition) collectConjuncts(condition, onConjuncts)
1037
+ const onResidual = push(onConjuncts, true)
1038
+ if (plan.type === 'HashJoin') {
1039
+ if (onResidual) plan.residual = onResidual
1040
+ else delete plan.residual
1041
+ } else if (condition) {
1042
+ // Nested-loop joins still need a condition when every conjunct was pushed.
1043
+ plan.condition = onResidual ?? {
1044
+ type: 'literal', value: true,
1045
+ positionStart: condition.positionStart, positionEnd: condition.positionEnd,
1046
+ }
1047
+ }
1048
+ const residual = push(whereConjuncts, false)
1049
+ if (leftSelect.joins.length) {
1050
+ // An inner join requires its ON condition to succeed, so it can reject
1051
+ // NULL rows from an earlier outer join. An outer join's own ON cannot.
1052
+ const leftProofs = joinType === 'INNER' || joinType === 'LEFT' ? [...proofs] : []
1053
+ if (joinType === 'INNER') {
1054
+ if (condition) leftProofs.push(rejectionProof(condition))
1055
+ if (plan.type === 'HashJoin') leftProofs.push(...plan.leftKeys.map(rejectionProof))
1056
+ }
1057
+ leftWhere = pushJoinPredicates(plan.left, leftWhere, leftSelect, tables, cteColumns, leftProofs)
1058
+ }
1059
+ plan.left = filterJoinInput(plan.left, leftWhere)
1060
+ plan.right = filterJoinInput(plan.right, rightWhere)
1061
+ return residual
1062
+ }
983
1063
 
984
- if (!where) return undefined
985
- /** @type {ExprNode[]} */
986
- const whereConjuncts = []
987
- collectConjuncts(where, whereConjuncts)
988
- const whereTargets = classify(whereConjuncts)
989
- return push(whereConjuncts, whereTargets)
1064
+ /**
1065
+ * @param {JoinClause} join
1066
+ * @returns {FromSubquery | FromTable}
1067
+ */
1068
+ function joinSource(join) {
1069
+ return join.subquery
1070
+ ? { ...join.subquery, alias: join.alias }
1071
+ : { ...join, type: 'table' }
1072
+ }
1073
+
1074
+ /**
1075
+ * Keep LIMIT, projection and shared CTE plans intact. A filter outside these
1076
+ * boundaries still reduces the rows entering the join.
1077
+ *
1078
+ * @param {QueryPlan} plan
1079
+ * @param {ExprNode | undefined} where
1080
+ * @returns {QueryPlan}
1081
+ */
1082
+ function filterJoinInput(plan, where) {
1083
+ if (!where) return plan
1084
+ if (plan.type === 'Scan' && plan.hints.limit === undefined && !plan.hints.offset) {
1085
+ return { ...plan, hints: { ...plan.hints, where: andPredicate(plan.hints.where, where) } }
1086
+ }
1087
+ return { type: 'Filter', condition: where, child: plan }
1088
+ }
1089
+
1090
+ /**
1091
+ * Conservative proof for an expression with references to the side being
1092
+ * NULL-extended. Unknown functions (including COALESCE) and CASE
1093
+ * are deliberately not assumed to propagate NULL.
1094
+ *
1095
+ * @param {ExprNode} expr
1096
+ * @param {(expr: ExprNode) => boolean} isMissing
1097
+ * @returns {boolean}
1098
+ */
1099
+ function nullWhenMissing(expr, isMissing) {
1100
+ if (expr.type === 'identifier') return isMissing(expr)
1101
+ if (expr.type === 'literal') return expr.value == null
1102
+ if (expr.type === 'cast') return nullWhenMissing(expr.expr, isMissing)
1103
+ if (expr.type === 'unary') return ['NOT', '+', '-'].includes(expr.op) && nullWhenMissing(expr.argument, isMissing)
1104
+ if (expr.type === 'in valuelist') return nullWhenMissing(expr.expr, isMissing)
1105
+ if (expr.type === 'binary' && ['=', '==', '!=', '<>', '<', '>', '<=', '>=', 'LIKE', '+', '-', '*', '/', '%', '||'].includes(expr.op)) {
1106
+ return nullWhenMissing(expr.left, isMissing) || nullWhenMissing(expr.right, isMissing)
1107
+ }
1108
+ return false
1109
+ }
1110
+
1111
+ /**
1112
+ * @param {ExprNode} expr
1113
+ * @param {(expr: ExprNode) => boolean} isMissing
1114
+ * @returns {boolean}
1115
+ */
1116
+ function rejectsNulls(expr, isMissing) {
1117
+ if (nullWhenMissing(expr, isMissing)) return true
1118
+ if (expr.type === 'unary' && expr.op === 'IS NOT NULL') return nullWhenMissing(expr.argument, isMissing)
1119
+ if (expr.type === 'binary' && expr.op === 'AND') return rejectsNulls(expr.left, isMissing) || rejectsNulls(expr.right, isMissing)
1120
+ if (expr.type === 'binary' && expr.op === 'OR') return rejectsNulls(expr.left, isMissing) && rejectsNulls(expr.right, isMissing)
1121
+ return false
990
1122
  }
991
1123
 
992
1124
  /**
@@ -1224,3 +1356,54 @@ function isAllCountStar(columns) {
1224
1356
  !col.expr.filter
1225
1357
  )
1226
1358
  }
1359
+
1360
+ /**
1361
+ * Pulls plain equality correlations out of a row-preserving EXISTS query.
1362
+ * Limits and aggregation must stay on the per-outer-row execution path.
1363
+ *
1364
+ * @param {Statement} query
1365
+ * @param {string[]} outerScope
1366
+ * @param {Record<string, AsyncDataSource>} [tables]
1367
+ * @param {Map<string, string[]>} [cteColumns]
1368
+ * @returns {{ query: SelectStatement, left: IdentifierNode[] } | undefined}
1369
+ */
1370
+ function decorrelateExists(query, outerScope, tables, cteColumns) {
1371
+ if (query.type !== 'select' || query.limit !== undefined || query.offset ||
1372
+ query.groupBy.length || query.having ||
1373
+ query.columns.some(col => col.type === 'derived' && (findAggregate(col.expr) || col.expr.type === 'window'))) return
1374
+ const scope = statementScope(query) ?? []
1375
+ /** @type {IdentifierNode[]} */
1376
+ const left = []
1377
+ /** @type {DerivedColumn[]} */
1378
+ const columns = []
1379
+ /** @type {ExprNode[]} */
1380
+ const conjuncts = []
1381
+ if (query.where) collectConjuncts(query.where, conjuncts)
1382
+ /** @type {ExprNode | undefined} */
1383
+ let where
1384
+ for (const term of conjuncts) {
1385
+ const previousKeys = columns.length
1386
+ if (term.type === 'binary' && term.op === '=' && term.left.type === 'identifier' && term.right.type === 'identifier') {
1387
+ for (const [inner, outer] of [[term.left, term.right], [term.right, term.left]]) {
1388
+ if (inner.prefix && scope.includes(inner.prefix) && outer.prefix &&
1389
+ outerScope.includes(outer.prefix) && !scope.includes(outer.prefix)) {
1390
+ left.push(outer)
1391
+ columns.push({ type: 'derived', expr: inner, alias: `__exists_key_${columns.length}`, positionStart: inner.positionStart, positionEnd: inner.positionEnd })
1392
+ break
1393
+ }
1394
+ }
1395
+ if (columns.length > previousKeys) continue
1396
+ }
1397
+ where = andPredicate(where, term)
1398
+ }
1399
+ if (!left.length) return
1400
+ // Check every remaining clause, including nested subqueries and projections,
1401
+ // before discarding the projection and ordering that EXISTS does not read.
1402
+ /** @type {IdentifierNode[]} */
1403
+ const remaining = []
1404
+ collectColumnsFromExpr({ type: 'subquery', subquery: { ...query, where }, positionStart: query.positionStart, positionEnd: query.positionEnd }, remaining, undefined, {
1405
+ tables, cteColumns, outerAliases: new Set(outerScope),
1406
+ })
1407
+ if (remaining.length) return
1408
+ return { query: { ...query, columns, where, distinct: false, orderBy: [] }, left }
1409
+ }
@@ -107,6 +107,8 @@ export interface SemiJoinNode {
107
107
  left: QueryPlan
108
108
  right: QueryPlan
109
109
  expr: ExprNode
110
+ // EXISTS equality keys; NOT uses anti-join rather than NOT IN null semantics.
111
+ keys?: ExprNode[]
110
112
  not: boolean
111
113
  }
112
114
 
package/src/types.d.ts CHANGED
@@ -145,6 +145,7 @@ export interface ScanTopK {
145
145
  export interface ScanRequest {
146
146
  columns: readonly ColumnDemand[]
147
147
  topK?: ScanTopK
148
+ /** Source-local predicate: identifiers use bare physical column names. */
148
149
  filter?: ExprNode
149
150
  limit?: number
150
151
  offset?: number
@@ -271,7 +272,8 @@ export interface ScanResults {
271
272
  */
272
273
  export interface ScanOptions {
273
274
  columns?: string[] // columns needed (undefined means all columns)
274
- where?: ExprNode // where clause
275
+ /** Source-local predicate: identifiers use bare physical column names. */
276
+ where?: ExprNode
275
277
  // important: only apply limit/offset if where is fully applied by the data source
276
278
  // otherwise, the data source must return at least enough rows to ensure the engine
277
279
  // can apply limit/offset correctly after filtering
@@ -285,6 +287,7 @@ export interface ScanOptions {
285
287
  */
286
288
  export interface ScanColumnOptions {
287
289
  column: string
290
+ /** Source-local predicate: identifiers use bare physical column names. */
288
291
  where?: ExprNode
289
292
  limit?: number
290
293
  offset?: number
@@ -131,7 +131,7 @@ export const FUNCTION_SIGNATURES = {
131
131
  SPLIT_PART: { min: 3, max: 3, signature: 'string, delimiter, index' },
132
132
  STRING_SPLIT: { min: 2, max: 2, signature: 'string, delimiter' },
133
133
  REGEXP_SUBSTR: { min: 2, max: 4, signature: 'string, pattern[, position[, occurrence]]' },
134
- REGEXP_EXTRACT: { min: 2, max: 4, signature: 'string, pattern[, position[, occurrence]]' },
134
+ REGEXP_EXTRACT: { min: 2, max: 3, signature: 'string, pattern[, group]' },
135
135
  REGEXP_REPLACE: { min: 3, max: 5, signature: 'string, pattern, replacement[, position[, occurrence]]' },
136
136
  REGEXP_MATCHES: { min: 2, max: 2, signature: 'string, pattern' },
137
137
  REGEXP_LIKE: { min: 2, max: 2, signature: 'string, pattern' },