squirreling 0.16.13 → 0.16.15

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
@@ -147,7 +147,7 @@ Squirreling mostly follows the SQL standard. The following features are supporte
147
147
  - `GROUP BY` and `HAVING` clauses, including `GROUP BY ALL`
148
148
  - Set operations: `UNION`, `UNION ALL`, `INTERSECT`, `INTERSECT ALL`, `EXCEPT`, `EXCEPT ALL`
149
149
  - `SHOW TABLES` to list the available tables
150
- - Expressions: `CASE`, `CAST`, `TRY_CAST`, `BETWEEN`, `IN`, `LIKE`, `IS NULL`, `IS NOT NULL`, string concatenation `||`
150
+ - Expressions: `CASE`, `CAST`, `TRY_CAST`, `BETWEEN`, `IN`, `LIKE`, `IS [NOT] NULL`, `IS [NOT] TRUE`, `IS [NOT] FALSE`, string concatenation `||`
151
151
  - Subscript access: zero-based array indexing `col[0]`, struct field access `col['field']`, and chains like `col[0].field`
152
152
 
153
153
  ### Quoting
@@ -158,13 +158,13 @@ Squirreling mostly follows the SQL standard. The following features are supporte
158
158
 
159
159
  ### Functions
160
160
 
161
- - Aggregate: `COUNT`, `COUNTIF`, `SUM`, `AVG`, `MIN`, `MAX`, `MIN_BY`, `MAX_BY`, `ANY_VALUE`, `MEDIAN`, `PERCENTILE_CONT`, `APPROX_QUANTILE`, `STDDEV_POP`, `STDDEV_SAMP`, `ARRAY_AGG`, `JSON_ARRAYAGG`, `STRING_AGG`
161
+ - Aggregate: `COUNT`, `COUNTIF`, `SUM`, `AVG`, `MIN`, `MAX`, `MIN_BY`, `MAX_BY`, `ANY_VALUE`, `MEDIAN`, `PERCENTILE_CONT`, `APPROX_QUANTILE`, `STDDEV_POP`, `STDDEV_SAMP`, `ARRAY_AGG`, `JSON_ARRAYAGG`, `STRING_AGG`, `GROUP_CONCAT`
162
162
  - Window: `ROW_NUMBER`, `LAG`, `LEAD`
163
- - String: `CONCAT`, `SUBSTRING`, `REPLACE`, `LENGTH`, `OCTET_LENGTH`, `UPPER`, `LOWER`, `TRIM`, `LEFT`, `RIGHT`, `INSTR`, `POSITION`, `STRPOS`, `SPLIT_PART`, `STRING_SPLIT`
163
+ - String: `CONCAT`, `SUBSTRING`, `REPLACE`, `LENGTH`, `OCTET_LENGTH`, `UPPER`, `LOWER`, `TRIM`, `LEFT`, `RIGHT`, `INSTR`, `POSITION`, `STRPOS`, `SPLIT_PART`, `STRING_SPLIT`, `CHR`
164
164
  - Math: `ABS`, `SIGN`, `CEIL`, `FLOOR`, `ROUND`, `MOD`, `RAND`, `RANDOM`, `LN`, `LOG10`, `EXP`, `POWER`, `SQRT`
165
165
  - Trig: `SIN`, `COS`, `TAN`, `COT`, `ASIN`, `ACOS`, `ATAN`, `ATAN2`, `DEGREES`, `RADIANS`, `PI`
166
- - Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `DATE_DIFF`, `DATEDIFF`, `DATE_PART`, `DATE_TRUNC`, `EPOCH`, `EXTRACT`, `INTERVAL`
167
- - Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_EXTRACT_STRING`, `JSON_OBJECT`, `JSON_ARRAY_LENGTH`, `JSON_VALID`, `JSON_TYPE`, `JSON_KEYS`
166
+ - Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `NOW`, `DATE_DIFF`, `DATEDIFF`, `DATE_PART`, `DATE_TRUNC`, `EPOCH`, `EXTRACT`, `INTERVAL`
167
+ - Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_EXTRACT_STRING`, `JSON_EXTRACT_SCALAR`, `JSON_OBJECT`, `JSON_ARRAY_LENGTH`, `JSON_VALID`, `JSON_TYPE`, `JSON_KEYS`
168
168
  - Array: `ARRAY_LENGTH`, `ARRAY_POSITION`, `ARRAY_CONTAINS`, `ARRAY_SORT`, `ARRAY_APPEND`, `ARRAY_CONCAT`, `LEN`, `CARDINALITY`, `SIZE`
169
169
  - Table functions: `UNNEST`, `EXPLODE`, `JSON_EACH`
170
170
  - Regex: `REGEXP_SUBSTR`, `REGEXP_EXTRACT`, `REGEXP_REPLACE`, `REGEXP_MATCHES`, `REGEXP_LIKE`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.16.13",
3
+ "version": "0.16.15",
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
  "@eslint/js": "10.0.1",
43
- "@types/node": "26.6.5",
43
+ "@types/node": "26.6.6",
44
44
  "@vitest/coverage-v8": "5.0.5",
45
45
  "eslint": "10.13.0",
46
- "eslint-plugin-jsdoc": "65.4.0",
46
+ "eslint-plugin-jsdoc": "65.4.2",
47
47
  "globals": "17.13.0",
48
48
  "typescript": "7.0.2",
49
49
  "vitest": "5.0.5"
package/src/ast.d.ts CHANGED
@@ -92,7 +92,7 @@ export interface IdentifierNode extends AstBase {
92
92
 
93
93
  export interface UnaryNode extends AstBase {
94
94
  type: 'unary'
95
- op: 'NOT' | 'IS NULL' | 'IS NOT NULL' | '-'
95
+ op: 'NOT' | 'IS NULL' | 'IS NOT NULL' | 'IS TRUE' | 'IS NOT TRUE' | 'IS FALSE' | 'IS NOT FALSE' | '-'
96
96
  argument: ExprNode
97
97
  }
98
98
 
@@ -7,13 +7,15 @@ import { keyify } from './utils.js'
7
7
  /**
8
8
  * Incremental state for one streamable aggregate. Shared by the streaming
9
9
  * aggregate executor and the scanColumn fast path so COUNT/COUNTIF/SUM/AVG/
10
- * MIN/MAX fold semantics live in one place.
10
+ * MIN/MAX/MIN_BY/MAX_BY fold semantics live in one place.
11
11
  *
12
12
  * @typedef {{
13
13
  * count: number,
14
14
  * sum: number,
15
15
  * min: SqlPrimitive,
16
16
  * max: SqlPrimitive,
17
+ * best?: SqlPrimitive,
18
+ * bestKey?: SqlPrimitive,
17
19
  * seen: Set<unknown> | null,
18
20
  * }} Accumulator
19
21
  */
@@ -41,8 +43,9 @@ export function newAccumulator(funcName, distinct) {
41
43
  * @param {string} funcName
42
44
  * @param {Accumulator} acc
43
45
  * @param {SqlPrimitive} value
46
+ * @param {SqlPrimitive} [key] - comparison key for MIN_BY/MAX_BY
44
47
  */
45
- export function updateAccumulator(funcName, acc, value) {
48
+ export function updateAccumulator(funcName, acc, value, key) {
46
49
  switch (funcName) {
47
50
  case 'COUNT':
48
51
  if (value == null) break
@@ -58,6 +61,19 @@ export function updateAccumulator(funcName, acc, value) {
58
61
  case 'MAX':
59
62
  if (value != null && (acc.max === null || value > acc.max)) acc.max = value
60
63
  break
64
+ case 'MIN_BY':
65
+ case 'ARG_MIN':
66
+ case 'MAX_BY':
67
+ case 'ARG_MAX': {
68
+ if (value == null || key == null) break
69
+ const isMin = funcName === 'MIN_BY' || funcName === 'ARG_MIN'
70
+ // Match buffered aggregation: ignore null pairs and keep the first tie.
71
+ if (acc.bestKey == null || (isMin ? key < acc.bestKey : key > acc.bestKey)) {
72
+ acc.best = value
73
+ acc.bestKey = key
74
+ }
75
+ break
76
+ }
61
77
  default: { // SUM, AVG
62
78
  if (value == null) break
63
79
  const num = Number(value)
@@ -84,6 +100,10 @@ export function finalizeAccumulator(funcName, acc) {
84
100
  case 'AVG': return acc.count === 0 ? null : acc.sum / acc.count
85
101
  case 'MIN': return acc.min
86
102
  case 'MAX': return acc.max
103
+ case 'MIN_BY':
104
+ case 'ARG_MIN':
105
+ case 'MAX_BY':
106
+ case 'ARG_MAX': return acc.best ?? null
87
107
  default: return null
88
108
  }
89
109
  }
@@ -193,6 +193,18 @@ export function executeHashAggregate(plan, context) {
193
193
  * @returns {QueryResults}
194
194
  */
195
195
  export function executeScalarAggregate(plan, context) {
196
+ if (context.deferScans && plan.child.type === 'Scan') {
197
+ const child = executeScan(plan.child, context)
198
+ return {
199
+ columns: selectColumnNames(plan.columns, child.columns),
200
+ numRows: plan.having ? undefined : 1,
201
+ maxRows: 1,
202
+ async *rows() {
203
+ context.signal?.throwIfAborted()
204
+ yield* executeScalarAggregate(plan, { ...context, deferScans: false }).rows()
205
+ },
206
+ }
207
+ }
196
208
  // Fast path: use scanColumn when available
197
209
  const columnScan = tryColumnScanAggregate(plan, context)
198
210
  if (columnScan?.rows) {
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @import { ExprNode, SqlPrimitive } from '../types.js'
3
+ * @import { QueryPlan } from '../plan/types.js'
4
+ */
5
+
6
+ // Bound filter memory and match the session-index pruning limit.
7
+ const MAX_DYNAMIC_FILTER_KEYS = 256
8
+
9
+ /**
10
+ * Bounded, per-column key sets. Once a column exceeds the cap, release it and
11
+ * stop collecting that column. Unsupported values disable that column too.
12
+ *
13
+ * @param {number} count
14
+ * @param {boolean} [hash] - preserve the hash join's serialized key equivalences
15
+ * @returns {{ add(values: SqlPrimitive[]): void, apply(plan: QueryPlan, keys: ExprNode[]): QueryPlan }}
16
+ */
17
+ export function dynamicFilter(count, hash = false) {
18
+ /** @type {(Set<SqlPrimitive> | undefined)[]} */
19
+ const sets = Array.from({ length: count }, () => new Set())
20
+ let active = true
21
+ return {
22
+ /**
23
+ * @param {SqlPrimitive[]} values
24
+ * @returns {void}
25
+ */
26
+ add(values) {
27
+ if (!active) return
28
+ // Hash tuple serialization uses delimiters and serializes objects. Do
29
+ // not prune when those encodings can collide across column boundaries.
30
+ if (hash && values.some(value => typeof value === 'object' && value !== null ||
31
+ typeof value === 'string' && (value.includes('|') || ['[', '{', '"'].includes(value[0]) || value === 'null'))) {
32
+ sets.fill(undefined)
33
+ active = false
34
+ return
35
+ }
36
+ for (let i = 0; i < sets.length; i++) {
37
+ const set = sets[i]
38
+ const value = values[i]
39
+ if (!set || value == null) continue
40
+ if (!['string', 'number', 'bigint', 'boolean'].includes(typeof value) ||
41
+ typeof value === 'number' && !Number.isFinite(value)) {
42
+ sets[i] = undefined
43
+ continue
44
+ }
45
+ set.add(value)
46
+ if (hash && count > 1) {
47
+ if (typeof value === 'boolean') set.add(String(value))
48
+ if (value === 'true' || value === 'false') set.add(value === 'true')
49
+ }
50
+ if (set.size > MAX_DYNAMIC_FILTER_KEYS) sets[i] = undefined
51
+ }
52
+ active = sets.some(Boolean)
53
+ },
54
+ /**
55
+ * @param {QueryPlan} plan
56
+ * @param {ExprNode[]} keys
57
+ * @returns {QueryPlan}
58
+ */
59
+ apply(plan, keys) {
60
+ for (let i = 0; i < keys.length; i++) {
61
+ const set = sets[i]
62
+ if (set?.size) plan = pushFilter(plan, keys[i], [...set])
63
+ }
64
+ return plan
65
+ },
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Only traverse operators that preserve the meaning of a physical column.
71
+ * Limits, aggregates, windows and projections are deliberate barriers.
72
+ *
73
+ * @param {QueryPlan} plan
74
+ * @param {ExprNode} key
75
+ * @param {SqlPrimitive[]} values
76
+ * @returns {QueryPlan}
77
+ */
78
+ function pushFilter(plan, key, values) {
79
+ if (key?.type !== 'identifier') return plan
80
+ if (plan.type === 'Scan') {
81
+ if (key.prefix && key.prefix !== (plan.alias ?? plan.table) ||
82
+ plan.hints.limit !== undefined || plan.hints.offset || plan.topK) return plan
83
+ const position = { positionStart: key.positionStart, positionEnd: key.positionEnd }
84
+ /** @type {ExprNode} */
85
+ const filter = {
86
+ type: 'in valuelist', expr: key, ...position,
87
+ values: values.map(value => ({ type: 'literal', value, ...position })),
88
+ }
89
+ /** @type {ExprNode} */
90
+ const where = plan.hints.where
91
+ ? { type: 'binary', op: 'AND', left: plan.hints.where, right: filter, ...position }
92
+ : filter
93
+ return { ...plan, hints: { ...plan.hints, where } }
94
+ }
95
+ if (plan.type === 'Filter' || plan.type === 'Sort' && !plan.topK || plan.type === 'Distinct') {
96
+ return { ...plan, child: pushFilter(plan.child, key, values) }
97
+ }
98
+ if (plan.type === 'HashJoin' && key.prefix) {
99
+ // Restrict only preserved inputs of outer joins; restricting an optional
100
+ // input could turn a formerly matched row into a new null-extended row.
101
+ const left = plan.joinType === 'INNER' || plan.joinType === 'LEFT'
102
+ ? pushFilter(plan.left, key, values) : plan.left
103
+ const right = plan.joinType === 'INNER' || plan.joinType === 'RIGHT'
104
+ ? pushFilter(plan.right, key, values) : plan.right
105
+ return { ...plan, left, right }
106
+ }
107
+ return plan
108
+ }
109
+
110
+ /**
111
+ * Selectivity heuristic when exact filtered cardinalities are unavailable.
112
+ * Count conjuncts so a date plus a LIKE wins over a lone part-type predicate.
113
+ *
114
+ * @param {QueryPlan} plan
115
+ * @returns {number}
116
+ */
117
+ export function filterScore(plan) {
118
+ if (plan.type === 'Scan') return conjuncts(plan.hints.where)
119
+ if (plan.type === 'Filter') return conjuncts(plan.condition) + filterScore(plan.child)
120
+ if (plan.type === 'HashJoin' && plan.joinType === 'INNER') return Math.max(filterScore(plan.left), filterScore(plan.right))
121
+ return 0
122
+ }
123
+
124
+ /**
125
+ * @param {ExprNode} expr
126
+ * @returns {number}
127
+ */
128
+ function conjuncts(expr) {
129
+ return !expr ? 0 : expr.type === 'binary' && expr.op === 'AND'
130
+ ? conjuncts(expr.left) + conjuncts(expr.right) : 1
131
+ }
@@ -1,5 +1,5 @@
1
1
  import { selectedRowCount } from '../backend/batch.js'
2
- import { batchesToRows } from '../backend/batchAdapters.js'
2
+ import { batchesToRows, rowsToBatches } from '../backend/batchAdapters.js'
3
3
  import { dataSourceColumns, memorySource } from '../backend/dataSource.js'
4
4
  import { derivedAlias } from '../expression/alias.js'
5
5
  import { compileBatchExpression } from '../expression/batch.js'
@@ -98,6 +98,9 @@ export function executeStatement({ query, context, outerScope }) {
98
98
  * @returns {QueryResults}
99
99
  */
100
100
  export function executePlan({ plan, context }) {
101
+ if (context.currentTimestamp === undefined) {
102
+ context = { ...context, currentTimestamp: new Date().toISOString() }
103
+ }
101
104
  if (plan.type === 'Scan') {
102
105
  return executeScan(plan, context)
103
106
  } else if (plan.type === 'SingleRow') {
@@ -318,6 +321,37 @@ export function executeScan(plan, context, existingColumnResult) {
318
321
  const { tables, signal } = context
319
322
  const table = validateTable({ ...plan, tables })
320
323
  validateScan({ ...plan, tables })
324
+ if (context.deferScans && !existingColumnResult) {
325
+ const size = computeScanRows(table.numRows, plan.hints.limit, plan.hints.offset)
326
+ const columns = plan.hints.columns ?? dataSourceColumns(table)
327
+ /** @type {QueryResults} */
328
+ const deferred = {
329
+ columns,
330
+ numRows: plan.hints.where ? undefined : size,
331
+ maxRows: size,
332
+ async *rows() {
333
+ context.signal?.throwIfAborted()
334
+ yield* executeScan(plan, { ...context, deferScans: false }).rows()
335
+ },
336
+ }
337
+ // Preserve native batch execution above a deferred input. Legacy fallback
338
+ // remains at the source boundary if a source declines its column path.
339
+ if (table.prepareScan && table.schema || table.scanColumn && plan.hints.columns?.length === 1) {
340
+ deferred.batches = async function* deferredBatches() {
341
+ context.signal?.throwIfAborted()
342
+ const result = executeScan(plan, { ...context, deferScans: false })
343
+ if (result.batches) {
344
+ // Prepared sources may return extra fields or a different order.
345
+ // Align vectors with the metadata exposed before opening the scan.
346
+ const indexes = columns.map(column => result.columns.indexOf(column))
347
+ for await (const batch of result.batches()) {
348
+ yield { ...batch, columns: indexes.map(index => batch.columns[index]) }
349
+ }
350
+ } else yield* rowsToBatches(result.rows(), columns, { signal })
351
+ }
352
+ }
353
+ return deferred
354
+ }
321
355
  const { where, residual } = scanFilter(plan, dataSourceColumns(table))
322
356
  const hints = { ...plan.hints, where }
323
357
  if (residual) {
@@ -1,6 +1,7 @@
1
1
  import { evaluateExpr } from '../expression/evaluate.js'
2
2
  import { keyify, maxBounds } from './utils.js'
3
3
  import { executePlan } from './execute.js'
4
+ import { dynamicFilter, filterScore } from './dynamicFilter.js'
4
5
  import { yieldToEventLoop } from './yield.js'
5
6
 
6
7
  /**
@@ -222,14 +223,18 @@ export function executePositionalJoin(plan, context) {
222
223
  * @returns {QueryResults}
223
224
  */
224
225
  export function executeHashJoin(plan, context) {
225
- const left = executePlan({ plan: plan.left, context })
226
- const right = executePlan({ plan: plan.right, context })
227
- // Build the hash table on the smaller side when both sizes are known,
228
- // so joining a small table against a large one buffers the small one.
226
+ const left = executePlan({ plan: plan.left, context: { ...context, deferScans: true } })
227
+ const right = executePlan({ plan: plan.right, context: { ...context, deferScans: true } })
228
+ // Prefer exact sizes, then predicate selectivity, then upper bounds.
229
+ // A filtered scan's table size is only an upper bound, not its cardinality.
229
230
  // Swapping reorders output rows, which SQL leaves unspecified.
230
231
  const leftSize = left.numRows ?? left.maxRows
231
232
  const rightSize = right.numRows ?? right.maxRows
232
- const swap = leftSize !== undefined && rightSize !== undefined && leftSize < rightSize
233
+ const swap = left.numRows !== undefined && right.numRows !== undefined
234
+ ? leftSize < rightSize
235
+ : filterScore(plan.left) !== filterScore(plan.right)
236
+ ? filterScore(plan.left) > filterScore(plan.right)
237
+ : leftSize !== undefined && rightSize !== undefined && leftSize < rightSize
233
238
  return {
234
239
  columns: mergeColumnNames(left.columns, right.columns, plan.leftAlias, plan.rightAlias),
235
240
  async *rows() {
@@ -237,7 +242,7 @@ export function executeHashJoin(plan, context) {
237
242
  const rightTable = plan.rightAlias
238
243
  const { residual } = plan
239
244
  const build = swap ? left : right
240
- const probe = swap ? right : left
245
+ let probe = swap ? right : left
241
246
  const buildKeys = swap ? plan.leftKeys : plan.rightKeys
242
247
  const probeKeys = swap ? plan.rightKeys : plan.leftKeys
243
248
  // Which sides must also emit their unmatched rows
@@ -260,6 +265,7 @@ export function executeHashJoin(plan, context) {
260
265
  // otherwise rows with NULL join keys are released immediately.
261
266
  /** @type {Map<string | number | bigint | boolean, AsyncRow[]>} */
262
267
  const hashMap = new Map()
268
+ const filters = dynamicFilter(buildKeys.length, true)
263
269
  /** @type {AsyncRow[] | undefined} */
264
270
  const buildRows = buildOuter ? [] : undefined
265
271
  /** @type {string[]} */
@@ -281,6 +287,7 @@ export function executeHashJoin(plan, context) {
281
287
  // SQL semantics: NULL never equals anything, so a row with any NULL
282
288
  // join key is excluded from the hash table.
283
289
  if (keyValues.some(v => v == null)) continue
290
+ if (!probeOuter) filters.add(keyValues)
284
291
  const key = keyify(...keyValues)
285
292
  let bucket = hashMap.get(key)
286
293
  if (!bucket) {
@@ -295,8 +302,14 @@ export function executeHashJoin(plan, context) {
295
302
  /** @type {Set<AsyncRow> | undefined} */
296
303
  const matchedBuildRows = buildOuter ? new Set() : undefined
297
304
 
305
+ if (!probeOuter && !hashMap.size && !buildOuter) return
306
+ if (!probeOuter && hashMap.size) {
307
+ const filtered = filters.apply(swap ? plan.right : plan.left, probeKeys)
308
+ probe = executePlan({ plan: filtered, context: { ...context, deferScans: true } })
309
+ }
310
+
298
311
  // Probe phase: stream the other side
299
- for await (const probeRow of probe.rows()) {
312
+ for await (const probeRow of !probeOuter && !hashMap.size ? [] : probe.rows()) {
300
313
  context.signal?.throwIfAborted()
301
314
 
302
315
  if (!probeCols) {
@@ -1,5 +1,6 @@
1
1
  import { evaluateExpr } from '../expression/evaluate.js'
2
2
  import { executePlan } from './execute.js'
3
+ import { dynamicFilter } from './dynamicFilter.js'
3
4
  import { sqlEquals } from './utils.js'
4
5
  import { yieldToEventLoop } from './yield.js'
5
6
 
@@ -19,7 +20,7 @@ import { yieldToEventLoop } from './yield.js'
19
20
  */
20
21
  export function executeSemiJoin(plan, context) {
21
22
  if (plan.keys) return executeExistsJoin(plan, context)
22
- const left = executePlan({ plan: plan.left, context })
23
+ const left = executePlan({ plan: plan.left, context: { ...context, deferScans: true } })
23
24
  return {
24
25
  columns: left.columns,
25
26
  maxRows: left.maxRows,
@@ -28,29 +29,30 @@ export function executeSemiJoin(plan, context) {
28
29
  const values = new Map()
29
30
  let hasNull = false
30
31
  let count = 0
31
- let built = false
32
32
  let rowIndex = 0
33
- for await (const row of left.rows()) {
33
+ const filters = dynamicFilter(1)
34
+ for await (const inner of executePlan({ plan: plan.right, context }).rows()) {
35
+ context.signal?.throwIfAborted()
36
+ if (++count % 4000 === 0) await yieldToEventLoop()
37
+ const value = await inner.cells[inner.columns[0]]()
38
+ if (value == null) hasNull = true
39
+ else {
40
+ const type = typeof value
41
+ let group = values.get(type)
42
+ if (!group) values.set(type, group = new Set())
43
+ group.add(value)
44
+ if (!plan.not) filters.add([value])
45
+ }
46
+ }
47
+ if (!plan.not && !values.size || plan.not && hasNull) return
48
+ const probe = executePlan({
49
+ plan: plan.not ? plan.left : filters.apply(plan.left, [plan.expr]),
50
+ context: { ...context, deferScans: true },
51
+ })
52
+ for await (const row of probe.rows()) {
34
53
  context.signal?.throwIfAborted()
35
54
  if (++rowIndex % 4000 === 0) await yieldToEventLoop()
36
55
  const value = await evaluateExpr({ node: plan.expr, row, rowIndex, context })
37
- if (!built) {
38
- for await (const inner of executePlan({ plan: plan.right, context }).rows()) {
39
- if (++count % 4000 === 0) {
40
- await yieldToEventLoop()
41
- context.signal?.throwIfAborted()
42
- }
43
- const value = await inner.cells[inner.columns[0]]()
44
- if (value == null) hasNull = true
45
- else {
46
- const type = typeof value
47
- let group = values.get(type)
48
- if (!group) values.set(type, group = new Set())
49
- group.add(value)
50
- }
51
- }
52
- built = true
53
- }
54
56
  const matched = count === 0 ? false : value == null ? null : contains(values, value) ? true : hasNull ? null : false
55
57
  if (matched !== null && matched !== plan.not) yield row
56
58
  }
@@ -87,36 +89,39 @@ function contains(values, value) {
87
89
  * @returns {QueryResults}
88
90
  */
89
91
  function executeExistsJoin(plan, context) {
90
- const left = executePlan({ plan: plan.left, context })
92
+ const left = executePlan({ plan: plan.left, context: { ...context, deferScans: true } })
91
93
  return {
92
94
  columns: left.columns,
93
95
  maxRows: left.maxRows,
94
96
  async *rows() {
95
97
  /** @type {KeyTrie} */
96
98
  const root = { groups: new Map() }
97
- let built = false
98
99
  let count = 0
99
- for await (const row of left.rows()) {
100
+ const filters = dynamicFilter(plan.keys?.length ?? 0)
101
+ for await (const inner of executePlan({ plan: plan.right, context }).rows()) {
100
102
  context.signal?.throwIfAborted()
101
103
  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
104
+ const values = await Promise.all(inner.columns.map(col => inner.cells[col]()))
105
+ if (values.some(value => value == null)) continue
106
+ if (!plan.not) filters.add(values)
107
+ let node = root
108
+ for (const value of values) {
109
+ const type = typeof value
110
+ let group = node.groups.get(type)
111
+ if (!group) node.groups.set(type, group = new Map())
112
+ let child = group.get(value)
113
+ if (!child) group.set(value, child = { groups: new Map() })
114
+ node = child
119
115
  }
116
+ }
117
+ if (!plan.not && !root.groups.size) return
118
+ const probe = executePlan({
119
+ plan: plan.not ? plan.left : filters.apply(plan.left, plan.keys ?? []),
120
+ context: { ...context, deferScans: true },
121
+ })
122
+ for await (const row of probe.rows()) {
123
+ context.signal?.throwIfAborted()
124
+ if (++count % 4000 === 0) await yieldToEventLoop()
120
125
  const values = await Promise.all((plan.keys ?? []).map(node => evaluateExpr({ node, row, context })))
121
126
  const matched = !values.some(value => value == null) && matchesKeys(root, values, 0)
122
127
  if (matched !== plan.not) yield row
@@ -24,7 +24,7 @@ const CHUNK_SIZE = 4000
24
24
  // Aggregate functions whose state can be accumulated one row at a time with
25
25
  // bounded memory. Aggregates outside this set (MEDIAN, ARRAY_AGG, STDDEV, ...)
26
26
  // need the full value set, so their queries buffer rows instead.
27
- const STREAMABLE_FUNCS = new Set(['COUNT', 'COUNTIF', 'SUM', 'AVG', 'MIN', 'MAX'])
27
+ const STREAMABLE_FUNCS = new Set(['COUNT', 'COUNTIF', 'SUM', 'AVG', 'MIN', 'MAX', 'MIN_BY', 'MAX_BY', 'ARG_MIN', 'ARG_MAX'])
28
28
 
29
29
  /**
30
30
  * Specs are keyed by aggregate node identity, so two aggregates that differ
@@ -178,6 +178,7 @@ export function planStreamingAggregates({ columns, having, orderBy, groupBy }, c
178
178
  }
179
179
  if (!STREAMABLE_FUNCS.has(funcName)) return false
180
180
  const star = node.args[0]?.type === 'star'
181
+ if (star && node.args.length > 1) return false
181
182
  if (lazy && !(star && !node.filter)) return false
182
183
  if (!star && !node.args.every(arg => isScalarExpr(arg))) return false
183
184
  if (node.filter && !isScalarExpr(node.filter)) return false
@@ -401,7 +402,7 @@ function accumulateChunk({ chunk, groupBy, specs, groups, needsRow, context }) {
401
402
  rows: chunk,
402
403
  signal: context.signal,
403
404
  // One flat tuple per row: group key values, then per spec its FILTER
404
- // result and its argument. The argument is only evaluated for rows that
405
+ // result and its arguments. Arguments are only evaluated for rows that
405
406
  // pass the FILTER, matching the buffered path.
406
407
  evaluate(row) {
407
408
  const pending = groupBy.map(expr => evaluateExpr({ node: expr, row, context }))
@@ -410,10 +411,14 @@ function accumulateChunk({ chunk, groupBy, specs, groups, needsRow, context }) {
410
411
  const passes = evaluateExpr({ node: node.filter, row, context })
411
412
  pending.push(passes)
412
413
  if (!star) {
413
- pending.push(passes.then(pass => pass ? evaluateExpr({ node: node.args[0], row, context }) : null))
414
+ for (const argument of node.args) {
415
+ pending.push(passes.then(pass => pass ? evaluateExpr({ node: argument, row, context }) : null))
416
+ }
414
417
  }
415
418
  } else if (!star) {
416
- pending.push(evaluateExpr({ node: node.args[0], row, context }))
419
+ for (const argument of node.args) {
420
+ pending.push(evaluateExpr({ node: argument, row, context }))
421
+ }
417
422
  }
418
423
  }
419
424
  return Promise.all(pending)
@@ -431,11 +436,12 @@ function accumulateChunk({ chunk, groupBy, specs, groups, needsRow, context }) {
431
436
  const spec = specs[s]
432
437
  const passes = spec.node.filter ? values[slot++] : true
433
438
  const arg = spec.star ? null : values[slot++]
439
+ const comparison = spec.node.args.length > 1 ? values[slot++] : undefined
434
440
  if (!passes) continue
435
441
  if (spec.star && spec.funcName === 'COUNT') {
436
442
  group.accumulators[s].count++
437
443
  } else {
438
- updateAccumulator(spec.funcName, group.accumulators[s], arg)
444
+ updateAccumulator(spec.funcName, group.accumulators[s], arg, comparison)
439
445
  }
440
446
  }
441
447
  },
@@ -470,16 +476,18 @@ function compileBatchAggregateInputs(groupBy, specs, columns, context) {
470
476
  for (const spec of specs) {
471
477
  if (spec.node.filter && !spec.star) return undefined
472
478
  if (spec.node.filter && referencesRowScope(spec.node.filter, columns, context)) return undefined
473
- if (!spec.star && referencesRowScope(spec.node.args[0], columns, context)) return undefined
479
+ if (!spec.star && spec.node.args.some(argument => referencesRowScope(argument, columns, context))) return undefined
474
480
  const filter = spec.node.filter
475
481
  ? compileBatchExpression(spec.node.filter, columns)
476
482
  : undefined
477
483
  const argument = spec.star
478
484
  ? undefined
479
485
  : compileBatchExpression(spec.node.args[0], columns)
480
- if (spec.node.filter && !filter || !spec.star && !argument) return undefined
486
+ const comparison = spec.node.args[1] && compileBatchExpression(spec.node.args[1], columns)
487
+ if (spec.node.filter && !filter || !spec.star && !argument || spec.node.args[1] && !comparison) return undefined
481
488
  filters.push(filter)
482
489
  args.push(argument)
490
+ if (spec.node.args[1]) args.push(comparison)
483
491
  }
484
492
  return {
485
493
  keys,
@@ -563,15 +571,18 @@ async function accumulateBatch({ batch, inputs, specs, groups, context, rowOffse
563
571
  }
564
572
  groups.set(key, group)
565
573
  }
574
+ let argumentIndex = 0
566
575
  for (let specIndex = 0; specIndex < specs.length; specIndex++) {
576
+ const spec = specs[specIndex]
577
+ const argument = args[argumentIndex++]
578
+ const comparison = spec.node.args[1] ? args[argumentIndex++] : undefined
567
579
  const filter = filters[specIndex]
568
580
  if (filter && !valueAt(filter, rowIndex)) continue
569
- const spec = specs[specIndex]
570
581
  if (spec.star && spec.funcName === 'COUNT') {
571
582
  group.accumulators[specIndex].count++
572
583
  } else {
573
- const argument = args[specIndex]
574
- updateAccumulator(spec.funcName, group.accumulators[specIndex], argument ? valueAt(argument, rowIndex) : null)
584
+ updateAccumulator(spec.funcName, group.accumulators[specIndex],
585
+ argument ? valueAt(argument, rowIndex) : null, comparison ? valueAt(comparison, rowIndex) : undefined)
575
586
  }
576
587
  }
577
588
  }
@@ -5,7 +5,7 @@ import { isStringFunc } from '../validation/functions.js'
5
5
  import { ColumnNotFoundError } from '../validation/tables.js'
6
6
  import { applyBinaryOp } from './binary.js'
7
7
  import { evaluateRegexpLike } from './regexp.js'
8
- import { applyCast, evaluateJsonExtract } from './scalar.js'
8
+ import { applyCast, applyUnaryOp, evaluateJsonExtract } from './scalar.js'
9
9
  import { evaluateStringFunc } from './strings.js'
10
10
 
11
11
  /**
@@ -52,10 +52,7 @@ function compileEvaluator(node, columns) {
52
52
  const vector = await argument.evaluate(context)
53
53
  return evaluateValues(context, function unaryValue(rowIndex) {
54
54
  const value = valueAt(vector, rowIndex)
55
- if (node.op === '-') return value == null ? null : -value
56
- if (node.op === 'NOT') return value == null ? null : !value
57
- if (node.op === 'IS NULL') return value == null
58
- return value != null
55
+ return applyUnaryOp(node.op, value)
59
56
  })
60
57
  },
61
58
  }
@@ -184,10 +181,7 @@ function compileValueKernel(node, state) {
184
181
  if (!argument) return undefined
185
182
  return function unaryValue(vectors, rowIndex, streamRowIndex) {
186
183
  const value = argument(vectors, rowIndex, streamRowIndex)
187
- if (node.op === '-') return value == null ? null : -value
188
- if (node.op === 'NOT') return value == null ? null : !value
189
- if (node.op === 'IS NULL') return value == null
190
- return value != null
184
+ return applyUnaryOp(node.op, value)
191
185
  }
192
186
  }
193
187
 
@@ -278,7 +272,7 @@ function compileFunctionKernel(node, state) {
278
272
  return sqlEquals(first, second) ? null : first
279
273
  }
280
274
  }
281
- if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING') {
275
+ if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING' || funcName === 'JSON_EXTRACT_SCALAR') {
282
276
  return function jsonValue(vectors, rowIndex, streamRowIndex) {
283
277
  const args = arguments_.map(function argumentValue(argument) {
284
278
  return argument(vectors, rowIndex, streamRowIndex)
@@ -327,7 +321,7 @@ function compileFunctionEvaluator(node, columns) {
327
321
  }
328
322
  }
329
323
  if (funcName !== 'NULLIF' && funcName !== 'JSON_VALUE' && funcName !== 'JSON_QUERY' &&
330
- funcName !== 'JSON_EXTRACT' && funcName !== 'JSON_EXTRACT_STRING' && funcName !== 'REGEXP_LIKE' &&
324
+ funcName !== 'JSON_EXTRACT' && funcName !== 'JSON_EXTRACT_STRING' && funcName !== 'JSON_EXTRACT_SCALAR' && funcName !== 'REGEXP_LIKE' &&
331
325
  !isStringFunc(funcName)) return undefined
332
326
  const regexpCache = funcName === 'REGEXP_LIKE' && node.args[1]?.type === 'literal' ? {} : undefined
333
327
  return {
@@ -338,7 +332,7 @@ function compileFunctionEvaluator(node, columns) {
338
332
  return evaluateValues(context, function functionValue(rowIndex, streamRowIndex) {
339
333
  const args = vectors.map(function argumentValue(vector) { return valueAt(vector, rowIndex) })
340
334
  if (funcName === 'NULLIF') return sqlEquals(args[0], args[1]) ? null : args[0]
341
- if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING') {
335
+ if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING' || funcName === 'JSON_EXTRACT_SCALAR') {
342
336
  return evaluateJsonExtract({ funcName, node, args, rowIndex: streamRowIndex + 1 })
343
337
  }
344
338
  if (funcName === 'REGEXP_LIKE') {
@@ -10,7 +10,7 @@ import { applyBinaryOp } from './binary.js'
10
10
  import { applyIntervalToDate, dateDiff, dateTrunc, extractField } from './date.js'
11
11
  import { evaluateMathFunc } from './math.js'
12
12
  import { evaluateRegexpFunc } from './regexp.js'
13
- import { applyCast, evaluateJsonExtract } from './scalar.js'
13
+ import { applyCast, applyUnaryOp, evaluateJsonExtract } from './scalar.js'
14
14
  import { evaluateSpatialFunc } from '../spatial/spatial.js'
15
15
  import { evaluateStringFunc } from './strings.js'
16
16
 
@@ -171,15 +171,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
171
171
  // Unary operators
172
172
  if (node.type === 'unary') {
173
173
  const val = await evaluateExpr({ node: node.argument, row, rowIndex, rows, context })
174
- if (node.op === '-') {
175
- if (val == null) return null
176
- return -val
177
- }
178
- // NOT UNKNOWN is UNKNOWN: JS ! would flip null to true and return rows
179
- // SQL excludes, e.g. WHERE NOT (nullable < 300)
180
- if (node.op === 'NOT') return val == null ? null : !val
181
- if (node.op === 'IS NULL') return val == null
182
- if (node.op === 'IS NOT NULL') return val != null
174
+ return applyUnaryOp(node.op, val)
183
175
  }
184
176
 
185
177
  // Binary operators
@@ -411,9 +403,12 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
411
403
  }
412
404
  }
413
405
 
414
- if (funcName === 'STRING_AGG') {
406
+ if (funcName === 'STRING_AGG' || funcName === 'GROUP_CONCAT') {
415
407
  const separatorNode = node.args[1]
416
- const separator = String(await evaluateExpr({ node: separatorNode, row: filteredRows[0] ?? { columns: [], cells: {} }, context }))
408
+ const separatorValue = separatorNode
409
+ ? await evaluateExpr({ node: separatorNode, row: filteredRows[0] ?? { columns: [], cells: {} }, context })
410
+ : ','
411
+ const separator = funcName === 'GROUP_CONCAT' && separatorValue == null ? '' : String(separatorValue)
417
412
  /** @type {string[]} */
418
413
  const values = []
419
414
  const allValues = await evaluateAll(argNode, filteredRows, context)
@@ -506,15 +501,15 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
506
501
  }
507
502
 
508
503
  if (funcName === 'CURRENT_DATE') {
509
- return new Date().toISOString().split('T')[0]
504
+ return (context.currentTimestamp ?? new Date().toISOString()).split('T')[0]
510
505
  }
511
506
 
512
507
  if (funcName === 'CURRENT_TIME') {
513
- return new Date().toISOString().split('T')[1].replace('Z', '')
508
+ return (context.currentTimestamp ?? new Date().toISOString()).split('T')[1].replace('Z', '')
514
509
  }
515
510
 
516
- if (funcName === 'CURRENT_TIMESTAMP') {
517
- return new Date().toISOString()
511
+ if (funcName === 'CURRENT_TIMESTAMP' || funcName === 'NOW') {
512
+ return context.currentTimestamp ?? new Date().toISOString()
518
513
  }
519
514
 
520
515
  if (funcName === 'JSON_OBJECT') {
@@ -676,7 +671,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
676
671
  })
677
672
  }
678
673
 
679
- if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING') {
674
+ if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING' || funcName === 'JSON_EXTRACT_SCALAR') {
680
675
  return evaluateJsonExtract({ funcName, node, args, rowIndex })
681
676
  }
682
677
 
@@ -3,7 +3,7 @@ import { ArgValueError, ExecutionError } from '../validation/executionErrors.js'
3
3
  import { toDate } from './date.js'
4
4
 
5
5
  /**
6
- * @import { CastNode, FunctionNode, SqlPrimitive } from '../types.js'
6
+ * @import { CastNode, FunctionNode, SqlPrimitive, UnaryNode } from '../types.js'
7
7
  */
8
8
 
9
9
  /**
@@ -72,7 +72,7 @@ export function evaluateJsonExtract({ funcName, node, args, rowIndex }) {
72
72
  })
73
73
  }
74
74
  }
75
- if (typeof json !== 'object' || json instanceof Date) {
75
+ if (funcName !== 'JSON_EXTRACT_SCALAR' && typeof json !== 'object' || json instanceof Date) {
76
76
  throw new ArgValueError({
77
77
  ...node,
78
78
  message: `first argument must be JSON string or object, got ${typeof json}`,
@@ -98,8 +98,28 @@ export function evaluateJsonExtract({ funcName, node, args, rowIndex }) {
98
98
  }
99
99
 
100
100
  if (current == null) return null
101
+ if (funcName === 'JSON_EXTRACT_SCALAR') {
102
+ return typeof current === 'object' ? null : String(current)
103
+ }
101
104
  if (funcName === 'JSON_EXTRACT_STRING') {
102
105
  return typeof current === 'object' ? JSON.stringify(current) : String(current)
103
106
  }
104
107
  return current
105
108
  }
109
+
110
+ /**
111
+ * Applies unary predicates identically in row and batch execution.
112
+ * @param {UnaryNode['op']} op
113
+ * @param {SqlPrimitive} value
114
+ * @returns {SqlPrimitive}
115
+ */
116
+ export function applyUnaryOp(op, value) {
117
+ if (op === '-') return value == null ? null : -value
118
+ if (op === 'NOT') return value == null ? null : !value
119
+ if (op === 'IS NULL') return value == null
120
+ if (op === 'IS NOT NULL') return value != null
121
+ if (op === 'IS TRUE') return value === true
122
+ if (op === 'IS NOT TRUE') return value !== true
123
+ if (op === 'IS FALSE') return value === false
124
+ return value !== false
125
+ }
@@ -35,6 +35,17 @@ export function evaluateStringFunc({ funcName, node, args, rowIndex }) {
35
35
  const [val] = args
36
36
  if (val == null) return null
37
37
 
38
+ if (funcName === 'CHR') {
39
+ const codePoint = typeof val === 'number' || typeof val === 'bigint' ? Number(val) : NaN
40
+ if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff ||
41
+ codePoint >= 0xd800 && codePoint <= 0xdfff) {
42
+ throw new ArgValueError({
43
+ ...node, message: 'expected a Unicode scalar value (0..0x10ffff, excluding surrogates)', rowIndex,
44
+ })
45
+ }
46
+ return String.fromCodePoint(codePoint)
47
+ }
48
+
38
49
  if (funcName === 'LENGTH') {
39
50
  if (typeof val === 'string' || Array.isArray(val)) return val.length
40
51
  throw new ArgValueError({
@@ -23,6 +23,7 @@ export interface CompileState {
23
23
  export interface BatchAggregateInputs {
24
24
  keys: CompiledBatchExpression[]
25
25
  filters: (CompiledBatchExpression | undefined)[]
26
+ // One value slot per aggregate, followed by its key for MIN_BY/MAX_BY.
26
27
  args: (CompiledBatchExpression | undefined)[]
27
28
  }
28
29
 
@@ -5,7 +5,7 @@ import { parseStatement } from './parse.js'
5
5
  import { consume, current, expect, match } from './state.js'
6
6
 
7
7
  /**
8
- * @import { ExprNode, ParserState } from '../types.js'
8
+ * @import { ExprNode, ParserState, UnaryNode } from '../types.js'
9
9
  */
10
10
 
11
11
  // Precedence (lowest to highest):
@@ -91,10 +91,18 @@ function parseComparison(state) {
91
91
  const left = parseConcat(state)
92
92
  const { positionStart } = left
93
93
 
94
- // IS [NOT] NULL
94
+ // IS [NOT] NULL/TRUE/FALSE
95
95
  if (match(state, 'keyword', 'IS')) {
96
- const op = match(state, 'keyword', 'NOT') ? 'IS NOT NULL' : 'IS NULL'
97
- expect(state, 'keyword', 'NULL')
96
+ const not = match(state, 'keyword', 'NOT')
97
+ const target = current(state)
98
+ if (target.type !== 'keyword' || !['NULL', 'TRUE', 'FALSE'].includes(target.value)) {
99
+ throw new SyntaxError({ expected: 'NULL, TRUE, or FALSE', after: not ? 'NOT' : 'IS', ...target })
100
+ }
101
+ consume(state)
102
+ /** @type {UnaryNode['op']} */
103
+ const op = target.value === 'TRUE' ? not ? 'IS NOT TRUE' : 'IS TRUE'
104
+ : target.value === 'FALSE' ? not ? 'IS NOT FALSE' : 'IS FALSE'
105
+ : not ? 'IS NOT NULL' : 'IS NULL'
98
106
  return {
99
107
  type: 'unary',
100
108
  op,
package/src/types.d.ts CHANGED
@@ -212,6 +212,10 @@ export interface ExecuteContext {
212
212
  // CTE references in subqueries re-planned during execution
213
213
  ctePlans?: Map<string, QueryPlan>
214
214
  cteColumns?: Map<string, string[]>
215
+ // UTC timestamp shared by current-time functions throughout one execution.
216
+ currentTimestamp?: string
217
+ // Delay opening join input scans until their rows are consumed.
218
+ deferScans?: boolean
215
219
  }
216
220
 
217
221
  // AsyncRow represents a row with async cell values
@@ -312,7 +316,7 @@ export interface UserDefinedFunction {
312
316
  arguments: FunctionSignature
313
317
  }
314
318
 
315
- export type AggregateFunc = 'COUNT' | 'COUNTIF' | 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'MIN_BY' | 'ARG_MIN' | 'MAX_BY' | 'ARG_MAX' | 'ANY_VALUE' | 'ARRAY_AGG' | 'LIST' | 'JSON_ARRAYAGG' | 'STDDEV_SAMP' | 'STDDEV_POP' | 'MEDIAN' | 'PERCENTILE_CONT' | 'APPROX_QUANTILE' | 'STRING_AGG'
319
+ export type AggregateFunc = 'COUNT' | 'COUNTIF' | 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'MIN_BY' | 'ARG_MIN' | 'MAX_BY' | 'ARG_MAX' | 'ANY_VALUE' | 'ARRAY_AGG' | 'LIST' | 'JSON_ARRAYAGG' | 'STDDEV_SAMP' | 'STDDEV_POP' | 'MEDIAN' | 'PERCENTILE_CONT' | 'APPROX_QUANTILE' | 'STRING_AGG' | 'GROUP_CONCAT'
316
320
 
317
321
  export type RegExpFunction = 'REGEXP_SUBSTR' | 'REGEXP_EXTRACT' | 'REGEXP_REPLACE' | 'REGEXP_MATCHES' | 'REGEXP_LIKE'
318
322
 
@@ -345,6 +349,7 @@ export type MathFunc =
345
349
  | 'RANDOM'
346
350
 
347
351
  export type StringFunc =
352
+ | 'CHR'
348
353
  | 'UPPER'
349
354
  | 'LOWER'
350
355
  | 'CONCAT'
@@ -11,7 +11,7 @@ export const niladicFuncs = ['CURRENT_DATE', 'CURRENT_TIME', 'CURRENT_TIMESTAMP'
11
11
  * @returns {name is AggregateFunc}
12
12
  */
13
13
  export function isAggregateFunc(name) {
14
- return ['COUNT', 'COUNTIF', 'SUM', 'AVG', 'MIN', 'MAX', 'MIN_BY', 'ARG_MIN', 'MAX_BY', 'ARG_MAX', 'ANY_VALUE', 'ARRAY_AGG', 'LIST', 'JSON_ARRAYAGG', 'STDDEV_SAMP', 'STDDEV_POP', 'MEDIAN', 'PERCENTILE_CONT', 'APPROX_QUANTILE', 'STRING_AGG'].includes(name)
14
+ return ['COUNT', 'COUNTIF', 'SUM', 'AVG', 'MIN', 'MAX', 'MIN_BY', 'ARG_MIN', 'MAX_BY', 'ARG_MAX', 'ANY_VALUE', 'ARRAY_AGG', 'LIST', 'JSON_ARRAYAGG', 'STDDEV_SAMP', 'STDDEV_POP', 'MEDIAN', 'PERCENTILE_CONT', 'APPROX_QUANTILE', 'STRING_AGG', 'GROUP_CONCAT'].includes(name)
15
15
  }
16
16
 
17
17
  /**
@@ -96,7 +96,7 @@ export function isCastType(name) {
96
96
  export function isStringFunc(name) {
97
97
  return [
98
98
  'UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'OCTET_LENGTH', 'SUBSTRING', 'SUBSTR', 'TRIM',
99
- 'REPLACE', 'LEFT', 'RIGHT', 'INSTR', 'POSITION', 'STRPOS', 'SPLIT_PART', 'STRING_SPLIT',
99
+ 'REPLACE', 'LEFT', 'RIGHT', 'INSTR', 'POSITION', 'STRPOS', 'SPLIT_PART', 'STRING_SPLIT', 'CHR',
100
100
  ].includes(name)
101
101
  }
102
102
 
@@ -114,6 +114,7 @@ export function isBinaryOp(op) {
114
114
  */
115
115
  export const FUNCTION_SIGNATURES = {
116
116
  // String functions
117
+ CHR: { min: 1, max: 1, signature: 'code_point' },
117
118
  UPPER: { min: 1, max: 1, signature: 'string' },
118
119
  LOWER: { min: 1, max: 1, signature: 'string' },
119
120
  LENGTH: { min: 1, max: 1, signature: 'string' },
@@ -137,6 +138,7 @@ export const FUNCTION_SIGNATURES = {
137
138
  REGEXP_LIKE: { min: 2, max: 2, signature: 'string, pattern' },
138
139
 
139
140
  // Date/time functions
141
+ NOW: { min: 0, max: 0, signature: '' },
140
142
  RANDOM: { min: 0, max: 0, signature: '' },
141
143
  RAND: { min: 0, max: 0, signature: '' },
142
144
  CURRENT_DATE: { min: 0, max: 0, signature: '' },
@@ -176,6 +178,7 @@ export const FUNCTION_SIGNATURES = {
176
178
  PI: { min: 0, max: 0, signature: '' },
177
179
 
178
180
  // JSON functions
181
+ JSON_EXTRACT_SCALAR: { min: 2, max: 2, signature: 'expression, path' },
179
182
  JSON_VALUE: { min: 2, max: 2, signature: 'expression, path' },
180
183
  JSON_QUERY: { min: 2, max: 2, signature: 'expression, path' },
181
184
  JSON_EXTRACT: { min: 2, max: 2, signature: 'expression, path' },
@@ -217,6 +220,7 @@ export const FUNCTION_SIGNATURES = {
217
220
  LEAST: { min: 1, signature: 'value1[, value2, ...]' },
218
221
 
219
222
  // Aggregate functions
223
+ GROUP_CONCAT: { min: 1, max: 2, signature: 'expression[, separator]' },
220
224
  COUNT: { min: 1, max: 1, signature: 'expression' },
221
225
  COUNTIF: { min: 1, max: 1, signature: 'condition' },
222
226
  SUM: { min: 1, max: 1, signature: 'expression' },