squirreling 0.12.7 → 0.12.8

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
@@ -154,14 +154,14 @@ Squirreling mostly follows the SQL standard. The following features are supporte
154
154
 
155
155
  ### Functions
156
156
 
157
- - Aggregate: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `MEDIAN`, `PERCENTILE_CONT`, `APPROX_QUANTILE`, `STDDEV_POP`, `STDDEV_SAMP`, `JSON_ARRAYAGG`, `STRING_AGG`
157
+ - Aggregate: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `MEDIAN`, `PERCENTILE_CONT`, `APPROX_QUANTILE`, `STDDEV_POP`, `STDDEV_SAMP`, `ARRAY_AGG`, `JSON_ARRAYAGG`, `STRING_AGG`
158
158
  - String: `CONCAT`, `SUBSTRING`, `REPLACE`, `LENGTH`, `UPPER`, `LOWER`, `TRIM`, `LEFT`, `RIGHT`, `INSTR`, `POSITION`, `STRPOS`
159
159
  - Math: `ABS`, `SIGN`, `CEIL`, `FLOOR`, `ROUND`, `MOD`, `RAND`, `RANDOM`, `LN`, `LOG10`, `EXP`, `POWER`, `SQRT`
160
160
  - Trig: `SIN`, `COS`, `TAN`, `COT`, `ASIN`, `ACOS`, `ATAN`, `ATAN2`, `DEGREES`, `RADIANS`, `PI`
161
161
  - Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `DATE_PART`, `DATE_TRUNC`, `EXTRACT`, `INTERVAL`
162
- - Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_OBJECT`, `JSON_ARRAY_LENGTH`
162
+ - Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_OBJECT`, `JSON_ARRAY_LENGTH`, `JSON_VALID`, `JSON_TYPE`
163
163
  - Array: `ARRAY_LENGTH`, `ARRAY_POSITION`, `ARRAY_SORT`, `CARDINALITY`
164
- - Table functions: `UNNEST`
164
+ - Table functions: `UNNEST`, `JSON_EACH`
165
165
  - Regex: `REGEXP_SUBSTR`, `REGEXP_EXTRACT`, `REGEXP_REPLACE`, `REGEXP_MATCHES`
166
166
  - Spatial: `ST_GeomFromText`, `ST_MakeEnvelope`, `ST_AsText`, `ST_Intersects`, `ST_Contains`, `ST_ContainsProperly`, `ST_Within`, `ST_Overlaps`, `ST_Touches`, `ST_Equals`, `ST_Crosses`, `ST_Covers`, `ST_CoveredBy`, `ST_DWithin`
167
167
  - Conditional: `COALESCE`, `NULLIF`, `GREATEST`, `LEAST`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.12.7",
3
+ "version": "0.12.8",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
package/src/ast.d.ts CHANGED
@@ -65,7 +65,7 @@ export interface FromFunction extends AstBase {
65
65
  funcName: string
66
66
  args: ExprNode[]
67
67
  alias?: string
68
- columnAlias?: string
68
+ columnAliases: string[]
69
69
  }
70
70
 
71
71
  export type ArithmeticOp = '+' | '-' | '*' | '/' | '%'
@@ -125,19 +125,31 @@ export function executePlan({ plan, context }) {
125
125
  }
126
126
 
127
127
  /**
128
- * Executes a table-valued function (e.g. UNNEST).
129
- * Evaluates the argument once against an empty row and yields one row per
130
- * element of the resulting array. Null or non-array input yields zero rows.
128
+ * Executes a table-valued function (e.g. UNNEST, JSON_EACH).
129
+ * Evaluates the argument once against the outer row (for lateral joins) or an
130
+ * empty row, then yields rows derived from the resulting value.
131
131
  *
132
132
  * @param {TableFunctionNode} plan
133
133
  * @param {ExecuteContext} context
134
134
  * @returns {QueryResults}
135
135
  */
136
136
  function executeTableFunction(plan, context) {
137
- if (plan.funcName !== 'UNNEST') {
138
- throw new Error(`Unsupported table function: ${plan.funcName}`)
137
+ if (plan.funcName === 'UNNEST') {
138
+ return executeUnnest(plan, context)
139
+ } else if (plan.funcName === 'JSON_EACH') {
140
+ return executeJsonEach(plan, context)
139
141
  }
140
- const columns = [plan.columnName]
142
+ throw new Error(`Unsupported table function: ${plan.funcName}`)
143
+ }
144
+
145
+ /**
146
+ * @param {TableFunctionNode} plan
147
+ * @param {ExecuteContext} context
148
+ * @returns {QueryResults}
149
+ */
150
+ function executeUnnest(plan, context) {
151
+ const columns = plan.columnNames
152
+ const [columnName] = columns
141
153
  return {
142
154
  columns,
143
155
  async *rows() {
@@ -149,9 +161,65 @@ function executeTableFunction(plan, context) {
149
161
  if (context.signal?.aborted) return
150
162
  yield {
151
163
  columns,
152
- cells: { [plan.columnName]: () => Promise.resolve(element) },
164
+ cells: { [columnName]: () => Promise.resolve(element) },
165
+ }
166
+ }
167
+ },
168
+ }
169
+ }
170
+
171
+ /**
172
+ * @param {TableFunctionNode} plan
173
+ * @param {ExecuteContext} context
174
+ * @returns {QueryResults}
175
+ */
176
+ function executeJsonEach(plan, context) {
177
+ const columns = plan.columnNames
178
+ const [keyCol, valueCol] = columns
179
+ return {
180
+ columns,
181
+ async *rows() {
182
+ /** @type {AsyncRow} */
183
+ const row = context.outerRow ?? { columns: [], cells: {} }
184
+ const value = await evaluateExpr({ node: plan.args[0], row, rowIndex: 1, context })
185
+ if (value == null) return
186
+ let parsed = value
187
+ if (typeof value === 'string') {
188
+ try {
189
+ parsed = JSON.parse(value)
190
+ } catch {
191
+ throw new Error('JSON_EACH(value): invalid JSON string. Argument must be valid JSON.')
192
+ }
193
+ }
194
+ if (Array.isArray(parsed)) {
195
+ for (let i = 0; i < parsed.length; i++) {
196
+ if (context.signal?.aborted) return
197
+ const k = i
198
+ const v = parsed[i]
199
+ yield {
200
+ columns,
201
+ cells: {
202
+ [keyCol]: () => Promise.resolve(k),
203
+ [valueCol]: () => Promise.resolve(v),
204
+ },
205
+ }
206
+ }
207
+ return
208
+ }
209
+ if (typeof parsed === 'object' && parsed !== null) {
210
+ for (const [k, v] of Object.entries(parsed)) {
211
+ if (context.signal?.aborted) return
212
+ yield {
213
+ columns,
214
+ cells: {
215
+ [keyCol]: () => Promise.resolve(k),
216
+ [valueCol]: () => Promise.resolve(v),
217
+ },
218
+ }
153
219
  }
220
+ return
154
221
  }
222
+ throw new Error('JSON_EACH(value): argument must be a JSON object or array')
155
223
  },
156
224
  }
157
225
  }
@@ -95,7 +95,7 @@ export function executeNestedLoopJoin(plan, context) {
95
95
  function executeLateralJoin(plan, context) {
96
96
  const left = executePlan({ plan: plan.left, context })
97
97
  // Right columns are known statically for table functions (the common case).
98
- const rightCols = plan.right.type === 'TableFunction' ? [plan.right.columnName] : []
98
+ const rightCols = plan.right.type === 'TableFunction' ? plan.right.columnNames : []
99
99
  return {
100
100
  columns: mergeColumnNames(left.columns, rightCols, plan.leftAlias, plan.rightAlias),
101
101
  async *rows() {
@@ -272,7 +272,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
272
272
  return values[lower] + (values[upper] - values[lower]) * (pos - lower)
273
273
  }
274
274
 
275
- if (funcName === 'JSON_ARRAYAGG') {
275
+ if (funcName === 'JSON_ARRAYAGG' || funcName === 'ARRAY_AGG') {
276
276
  if (node.distinct) {
277
277
  /** @type {SqlPrimitive[]} */
278
278
  const values = []
@@ -417,6 +417,40 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
417
417
  return result
418
418
  }
419
419
 
420
+ if (funcName === 'JSON_VALID') {
421
+ const value = args[0]
422
+ if (value == null) return null
423
+ if (typeof value !== 'string') return false
424
+ try {
425
+ JSON.parse(value)
426
+ return true
427
+ } catch {
428
+ return false
429
+ }
430
+ }
431
+
432
+ if (funcName === 'JSON_TYPE') {
433
+ let value = args[0]
434
+ if (value == null) return null
435
+ if (typeof value === 'string') {
436
+ try {
437
+ value = JSON.parse(value)
438
+ } catch {
439
+ throw new ArgValueError({
440
+ ...node,
441
+ message: 'invalid JSON string',
442
+ hint: 'Argument must be valid JSON.',
443
+ rowIndex,
444
+ })
445
+ }
446
+ }
447
+ if (value === null) return 'null'
448
+ if (Array.isArray(value)) return 'array'
449
+ if (value instanceof Date) return 'string'
450
+ if (typeof value === 'bigint') return 'number'
451
+ return typeof value
452
+ }
453
+
420
454
  if (funcName === 'JSON_ARRAY_LENGTH') {
421
455
  let arr = args[0]
422
456
  if (arr == null) return null
@@ -84,6 +84,30 @@ export function parseFunctionCall(state, positionStart) {
84
84
  })
85
85
  }
86
86
 
87
+ // Check for WITHIN GROUP (ORDER BY expr) clause — standard SQL ordered-set aggregate syntax.
88
+ // Supported for PERCENTILE_CONT: PERCENTILE_CONT(fraction) WITHIN GROUP (ORDER BY expr)
89
+ const withinTok = current(state)
90
+ if (match(state, 'keyword', 'WITHIN')) {
91
+ if (funcNameUpper !== 'PERCENTILE_CONT') {
92
+ throw new ParseError({
93
+ message: `WITHIN GROUP is only supported for PERCENTILE_CONT, not "${funcName}"`,
94
+ ...withinTok,
95
+ })
96
+ }
97
+ if (args.length !== 1) {
98
+ throw new ParseError({
99
+ message: `${funcName}: cannot combine WITHIN GROUP with a value argument`,
100
+ ...withinTok,
101
+ })
102
+ }
103
+ expect(state, 'keyword', 'GROUP')
104
+ expect(state, 'paren', '(')
105
+ expect(state, 'keyword', 'ORDER')
106
+ expect(state, 'keyword', 'BY')
107
+ args.push(parseExpression(state))
108
+ expect(state, 'paren', ')')
109
+ }
110
+
87
111
  // Validate argument count at parse time
88
112
  validateFunctionArgs(funcNameUpper, args.length, positionStart, state.lastPos, state.functions)
89
113
 
@@ -468,15 +468,22 @@ export function parseFromFunction(state) {
468
468
  validateFunctionArgs(funcName, args.length, positionStart, state.lastPos, state.functions)
469
469
 
470
470
  const alias = parseTableAlias(state)
471
- /** @type {string | undefined} */
472
- let columnAlias
471
+ /** @type {string[]} */
472
+ const columnAliases = []
473
473
  if (alias && match(state, 'paren', '(')) {
474
474
  const colStart = state.lastPos
475
- const colTok = expect(state, 'identifier')
476
- columnAlias = colTok.value
477
- if (match(state, 'comma')) {
475
+ while (true) {
476
+ const colTok = expect(state, 'identifier')
477
+ columnAliases.push(colTok.value)
478
+ if (!match(state, 'comma')) break
479
+ }
480
+ const maxCols = tableFunctionColumnCount(funcName)
481
+ if (columnAliases.length > maxCols) {
482
+ const colLabels = tableFunctionDefaultColumns(funcName).join(', ')
478
483
  throw new ParseError({
479
- message: `${funcName} produces a single column; only one column alias is allowed`,
484
+ message: maxCols === 1
485
+ ? `${funcName} produces a single column; only one column alias is allowed`
486
+ : `${funcName} produces at most ${maxCols} columns (${colLabels}); too many column aliases`,
480
487
  positionStart: colStart,
481
488
  positionEnd: state.lastPos,
482
489
  })
@@ -489,12 +496,31 @@ export function parseFromFunction(state) {
489
496
  funcName,
490
497
  args,
491
498
  alias,
492
- columnAlias,
499
+ columnAliases,
493
500
  positionStart,
494
501
  positionEnd: state.lastPos,
495
502
  }
496
503
  }
497
504
 
505
+ /**
506
+ * Default column names produced by a table-valued function.
507
+ * @param {string} funcName
508
+ * @returns {string[]}
509
+ */
510
+ export function tableFunctionDefaultColumns(funcName) {
511
+ if (funcName === 'JSON_EACH') return ['key', 'value']
512
+ return [funcName.toLowerCase()]
513
+ }
514
+
515
+ /**
516
+ * Maximum number of output columns a table-valued function can produce.
517
+ * @param {string} funcName
518
+ * @returns {number}
519
+ */
520
+ export function tableFunctionColumnCount(funcName) {
521
+ return tableFunctionDefaultColumns(funcName).length
522
+ }
523
+
498
524
  /**
499
525
  * Parses an optional table alias (e.g., "FROM users u" or "FROM users AS u")
500
526
  * @param {ParserState} state
@@ -1,3 +1,4 @@
1
+ import { tableFunctionDefaultColumns } from '../parse/parse.js'
1
2
  import { derivedAlias } from '../expression/alias.js'
2
3
 
3
4
  /**
@@ -18,13 +19,19 @@ export function fromAlias(from) {
18
19
  }
19
20
 
20
21
  /**
21
- * Returns the single output column name for a FROM table function.
22
+ * Returns the output column names for a FROM table function, applying any
23
+ * column aliases over the function's default column names.
22
24
  *
23
25
  * @param {FromFunction} from
24
- * @returns {string}
26
+ * @returns {string[]}
25
27
  */
26
- export function tableFunctionColumnName(from) {
27
- return from.columnAlias ?? from.funcName.toLowerCase()
28
+ export function tableFunctionColumnNames(from) {
29
+ const defaults = tableFunctionDefaultColumns(from.funcName)
30
+ const result = []
31
+ for (let i = 0; i < defaults.length; i++) {
32
+ result.push(from.columnAliases[i] ?? defaults[i])
33
+ }
34
+ return result
28
35
  }
29
36
 
30
37
  /**
@@ -330,18 +337,21 @@ export function inferSelectSourceColumns({ select, cteColumns, tables }) {
330
337
  }
331
338
 
332
339
  if (select.from.type === 'function') {
333
- // Table functions currently produce a single column
334
340
  if (!select.joins.length) {
335
- return [tableFunctionColumnName(select.from)]
341
+ return tableFunctionColumnNames(select.from)
336
342
  }
337
343
  /** @type {string[]} */
338
344
  const result = []
339
345
  const alias = fromAlias(select.from)
340
- result.push(`${alias}.${tableFunctionColumnName(select.from)}`)
346
+ for (const col of tableFunctionColumnNames(select.from)) {
347
+ result.push(`${alias}.${col}`)
348
+ }
341
349
  for (const join of select.joins) {
342
350
  const joinAlias = join.alias ?? join.table
343
351
  if (join.fromFunction) {
344
- result.push(`${joinAlias}.${tableFunctionColumnName(join.fromFunction)}`)
352
+ for (const col of tableFunctionColumnNames(join.fromFunction)) {
353
+ result.push(`${joinAlias}.${col}`)
354
+ }
345
355
  } else {
346
356
  for (const col of lookupTableColumns(join.table, cteColumns, tables)) {
347
357
  result.push(`${joinAlias}.${col}`)
@@ -365,7 +375,9 @@ export function inferSelectSourceColumns({ select, cteColumns, tables }) {
365
375
  for (const join of select.joins) {
366
376
  const joinAlias = join.alias ?? join.table
367
377
  if (join.fromFunction) {
368
- result.push(`${joinAlias}.${tableFunctionColumnName(join.fromFunction)}`)
378
+ for (const col of tableFunctionColumnNames(join.fromFunction)) {
379
+ result.push(`${joinAlias}.${col}`)
380
+ }
369
381
  } else {
370
382
  for (const col of lookupTableColumns(join.table, cteColumns, tables)) {
371
383
  result.push(`${joinAlias}.${col}`)
package/src/plan/plan.js CHANGED
@@ -3,7 +3,7 @@ import { parseSql } from '../parse/parse.js'
3
3
  import { findAggregate } from '../validation/aggregates.js'
4
4
  import { ColumnNotFoundError, TableNotFoundError } from '../validation/tables.js'
5
5
  import { validateNoIdentifiers, validateScan, validateTableRefs } from '../validation/tables.js'
6
- import { extractColumns, fromAlias, inferSelectSourceColumns, inferStatementColumns, tableFunctionColumnName } from './columns.js'
6
+ import { extractColumns, fromAlias, inferSelectSourceColumns, inferStatementColumns, tableFunctionColumnNames } from './columns.js'
7
7
 
8
8
  /**
9
9
  * @import { AsyncDataSource, ExprNode, DerivedColumn, IdentifierNode, JoinClause, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement, SetOperationStatement, Statement } from '../types.js'
@@ -321,7 +321,7 @@ function planTableFunction(from) {
321
321
  type: 'TableFunction',
322
322
  funcName: from.funcName,
323
323
  args: from.args,
324
- columnName: tableFunctionColumnName(from),
324
+ columnNames: tableFunctionColumnNames(from),
325
325
  }
326
326
  }
327
327
 
@@ -122,5 +122,5 @@ export interface TableFunctionNode {
122
122
  type: 'TableFunction'
123
123
  funcName: string
124
124
  args: ExprNode[]
125
- columnName: string
125
+ columnNames: string[]
126
126
  }
package/src/types.d.ts CHANGED
@@ -129,7 +129,7 @@ export interface UserDefinedFunction {
129
129
  arguments: FunctionSignature
130
130
  }
131
131
 
132
- export type AggregateFunc = 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'JSON_ARRAYAGG' | 'STDDEV_SAMP' | 'STDDEV_POP' | 'MEDIAN' | 'PERCENTILE_CONT' | 'APPROX_QUANTILE' | 'STRING_AGG'
132
+ export type AggregateFunc = 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'ARRAY_AGG' | 'JSON_ARRAYAGG' | 'STDDEV_SAMP' | 'STDDEV_POP' | 'MEDIAN' | 'PERCENTILE_CONT' | 'APPROX_QUANTILE' | 'STRING_AGG'
133
133
 
134
134
  export type RegExpFunction = 'REGEXP_SUBSTR' | 'REGEXP_EXTRACT' | 'REGEXP_REPLACE' | 'REGEXP_MATCHES'
135
135
 
@@ -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', 'SUM', 'AVG', 'MIN', 'MAX', 'JSON_ARRAYAGG', 'STDDEV_SAMP', 'STDDEV_POP', 'MEDIAN', 'PERCENTILE_CONT', 'APPROX_QUANTILE', 'STRING_AGG'].includes(name)
14
+ return ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'ARRAY_AGG', 'JSON_ARRAYAGG', 'STDDEV_SAMP', 'STDDEV_POP', 'MEDIAN', 'PERCENTILE_CONT', 'APPROX_QUANTILE', 'STRING_AGG'].includes(name)
15
15
  }
16
16
 
17
17
  /**
@@ -41,7 +41,7 @@ export function isRegexpFunc(name) {
41
41
  * @returns {boolean}
42
42
  */
43
43
  export function isTableFunction(name) {
44
- return ['UNNEST'].includes(name)
44
+ return ['UNNEST', 'JSON_EACH'].includes(name)
45
45
  }
46
46
 
47
47
  /**
@@ -166,7 +166,10 @@ export const FUNCTION_SIGNATURES = {
166
166
  JSON_EXTRACT: { min: 2, max: 2, signature: 'expression, path' },
167
167
  JSON_OBJECT: { min: 0, signature: 'key1, value1[, ...]' },
168
168
  JSON_ARRAY_LENGTH: { min: 1, max: 1, signature: 'array' },
169
+ JSON_VALID: { min: 1, max: 1, signature: 'value' },
170
+ JSON_TYPE: { min: 1, max: 1, signature: 'value' },
169
171
  JSON_ARRAYAGG: { min: 1, max: 1, signature: 'expression' },
172
+ ARRAY_AGG: { min: 1, max: 1, signature: 'expression' },
170
173
 
171
174
  // Array functions
172
175
  ARRAY_LENGTH: { min: 1, max: 1, signature: 'array' },
@@ -176,6 +179,7 @@ export const FUNCTION_SIGNATURES = {
176
179
 
177
180
  // Table functions (used in FROM clause)
178
181
  UNNEST: { min: 1, max: 1, signature: 'array' },
182
+ JSON_EACH: { min: 1, max: 1, signature: 'value' },
179
183
 
180
184
  // Conditional functions
181
185
  COALESCE: { min: 1, signature: 'value1, value2[, ...]' },
@@ -4,7 +4,7 @@ export const KEYWORDS = new Set([
4
4
  'DISTINCT', 'TRUE', 'FALSE', 'NULL', 'LIKE', 'IN', 'EXISTS', 'BETWEEN',
5
5
  'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'JOIN', 'INNER', 'LEFT', 'RIGHT',
6
6
  'FULL', 'OUTER', 'CROSS', 'POSITIONAL', 'LATERAL', 'ON', 'INTERVAL', 'DAY', 'MONTH', 'YEAR',
7
- 'HOUR', 'MINUTE', 'SECOND', 'FILTER',
7
+ 'HOUR', 'MINUTE', 'SECOND', 'FILTER', 'WITHIN',
8
8
  'UNION', 'INTERSECT', 'EXCEPT',
9
9
  ])
10
10