squirreling 0.12.13 → 0.12.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
@@ -154,13 +154,13 @@ 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`, `ARRAY_AGG`, `JSON_ARRAYAGG`, `STRING_AGG`
157
+ - Aggregate: `COUNT`, `COUNTIF`, `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
162
  - Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_OBJECT`, `JSON_ARRAY_LENGTH`, `JSON_VALID`, `JSON_TYPE`
163
- - Array: `ARRAY_LENGTH`, `ARRAY_POSITION`, `ARRAY_SORT`, `CARDINALITY`
163
+ - Array: `ARRAY_LENGTH`, `ARRAY_POSITION`, `ARRAY_CONTAINS`, `ARRAY_SORT`, `CARDINALITY`, `SIZE`
164
164
  - Table functions: `UNNEST`, `EXPLODE`, `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`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.12.13",
3
+ "version": "0.12.15",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -41,7 +41,7 @@
41
41
  "devDependencies": {
42
42
  "@types/node": "25.6.0",
43
43
  "@vitest/coverage-v8": "4.1.5",
44
- "eslint": "9.39.2",
44
+ "eslint": "9.39.4",
45
45
  "eslint-plugin-jsdoc": "62.9.0",
46
46
  "typescript": "6.0.3",
47
47
  "vitest": "4.1.5"
@@ -62,6 +62,14 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
62
62
  if (context.outerRow && context.outerAliases?.has(node.prefix) && node.name in context.outerRow.cells) {
63
63
  return context.outerRow.cells[node.name]()
64
64
  }
65
+ // Standalone `FROM UNNEST(...) AS alias` row has a single bare column;
66
+ // `alias.field` should struct-access that column's element.
67
+ if (context.scope?.includes(node.prefix) && row.columns.length === 1) {
68
+ const value = await row.cells[row.columns[0]]()
69
+ if (isPlainObject(value) && Object.prototype.hasOwnProperty.call(value, node.name)) {
70
+ return value[node.name]
71
+ }
72
+ }
65
73
  // Fall back to just the column part
66
74
  if (node.name in row.cells) {
67
75
  return row.cells[node.name]()
@@ -200,6 +208,17 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
200
208
  return count
201
209
  }
202
210
 
211
+ if (funcName === 'COUNTIF') {
212
+ const values = await Promise.all(filteredRows.map(row =>
213
+ evaluateExpr({ node: argNode, row, context })
214
+ ))
215
+ let count = 0
216
+ for (const v of values) {
217
+ if (v) count++
218
+ }
219
+ return count
220
+ }
221
+
203
222
  if (funcName === 'SUM' || funcName === 'AVG' || funcName === 'MIN' || funcName === 'MAX') {
204
223
  const rawValues = await Promise.all(filteredRows.map(row =>
205
224
  evaluateExpr({ node: argNode, row, context })
@@ -489,7 +508,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
489
508
  return arr.length
490
509
  }
491
510
 
492
- if (funcName === 'ARRAY_LENGTH' || funcName === 'CARDINALITY') {
511
+ if (funcName === 'ARRAY_LENGTH' || funcName === 'CARDINALITY' || funcName === 'SIZE') {
493
512
  const arr = args[0]
494
513
  if (!Array.isArray(arr)) return null
495
514
  if (funcName === 'ARRAY_LENGTH' && args.length === 2) {
@@ -519,6 +538,12 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
519
538
  return index === -1 ? null : index + 1
520
539
  }
521
540
 
541
+ if (funcName === 'ARRAY_CONTAINS') {
542
+ const [arr, target] = args
543
+ if (!Array.isArray(arr)) return null
544
+ return arr.includes(target)
545
+ }
546
+
522
547
  if (funcName === 'ARRAY_SORT') {
523
548
  const arr = args[0]
524
549
  if (!Array.isArray(arr)) return null
@@ -650,13 +675,15 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
650
675
  }
651
676
 
652
677
  // EXISTS and NOT EXISTS with subqueries
653
- if (node.type === 'exists') {
654
- const results = await executeStatement({ query: node.subquery, context }).rows().next()
655
- return results.done === false
656
- }
657
- if (node.type === 'not exists') {
658
- const results = await executeStatement({ query: node.subquery, context }).rows().next()
659
- return results.done === true
678
+ if (node.type === 'exists' || node.type === 'not exists') {
679
+ const outerScope = context.scope
680
+ const subContext = outerScope
681
+ ? { ...context, outerRow: row, outerAliases: new Set(outerScope) }
682
+ : context
683
+ const gen = executeStatement({ query: node.subquery, context: subContext, outerScope }).rows()
684
+ const results = await gen.next()
685
+ gen.return(undefined)
686
+ return node.type === 'exists' ? results.done === false : results.done === true
660
687
  }
661
688
 
662
689
  // CASE expressions
@@ -268,6 +268,11 @@ function collectColumnsFromExpr(expr, columns, aliases) {
268
268
  for (const id of inner) {
269
269
  if (id.prefix) columns.push(id)
270
270
  }
271
+ // FROM-function args (e.g. UNNEST in the subquery's FROM) are evaluated
272
+ // against the outer scope — the table function is itself the FROM, so
273
+ // any identifier inside its args must be correlated. Push them even if
274
+ // unprefixed so the outer scan reads the columns they reference.
275
+ collectFromFunctionArgs(sub, columns)
271
276
  }
272
277
  }
273
278
  // No columns: count(*), literal, interval
@@ -309,6 +314,33 @@ function collectColumnsFromStatement(stmt, columns) {
309
314
  for (const item of stmt.orderBy) collectColumnsFromExpr(item.expr, columns)
310
315
  }
311
316
 
317
+ /**
318
+ * Walks a subquery statement and collects identifiers from FROM-function args
319
+ * (e.g. UNNEST(col) in FROM). These identifiers are necessarily correlated
320
+ * outer references because the table function is the only source.
321
+ *
322
+ * @param {Statement} stmt
323
+ * @param {IdentifierNode[]} columns
324
+ */
325
+ function collectFromFunctionArgs(stmt, columns) {
326
+ if (stmt.type === 'compound') {
327
+ collectFromFunctionArgs(stmt.left, columns)
328
+ collectFromFunctionArgs(stmt.right, columns)
329
+ return
330
+ }
331
+ if (stmt.type === 'with') {
332
+ collectFromFunctionArgs(stmt.query, columns)
333
+ return
334
+ }
335
+ if (stmt.from?.type === 'function') {
336
+ for (const arg of stmt.from.args) {
337
+ collectColumnsFromExpr(arg, columns)
338
+ }
339
+ } else if (stmt.from?.type === 'subquery') {
340
+ collectFromFunctionArgs(stmt.from.query, columns)
341
+ }
342
+ }
343
+
312
344
  /**
313
345
  * Infers output columns for set-operation validation.
314
346
  *
package/src/plan/plan.js CHANGED
@@ -343,7 +343,7 @@ function planFrom({ select, ctePlans, cteColumns, hints, tables, outerScope }) {
343
343
  return { type: 'Scan', table: select.from.table, hints }
344
344
  } else if (select.from.type === 'function') {
345
345
  for (const arg of select.from.args) {
346
- validateNoIdentifiers(arg, select.from.funcName)
346
+ validateNoIdentifiers(arg, select.from.funcName, outerScope)
347
347
  }
348
348
  return planTableFunction(select.from)
349
349
  } else {
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' | 'ARRAY_AGG' | 'JSON_ARRAYAGG' | 'STDDEV_SAMP' | 'STDDEV_POP' | 'MEDIAN' | 'PERCENTILE_CONT' | 'APPROX_QUANTILE' | 'STRING_AGG'
132
+ export type AggregateFunc = 'COUNT' | 'COUNTIF' | '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', 'ARRAY_AGG', 'JSON_ARRAYAGG', 'STDDEV_SAMP', 'STDDEV_POP', 'MEDIAN', 'PERCENTILE_CONT', 'APPROX_QUANTILE', 'STRING_AGG'].includes(name)
14
+ return ['COUNT', 'COUNTIF', '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
  /**
@@ -182,8 +182,10 @@ export const FUNCTION_SIGNATURES = {
182
182
  // Array functions
183
183
  ARRAY_LENGTH: { min: 1, max: 2, signature: 'array[, dimension]' },
184
184
  ARRAY_POSITION: { min: 2, max: 2, signature: 'array, element' },
185
+ ARRAY_CONTAINS: { min: 2, max: 2, signature: 'array, element' },
185
186
  ARRAY_SORT: { min: 1, max: 1, signature: 'array' },
186
187
  CARDINALITY: { min: 1, max: 1, signature: 'array' },
188
+ SIZE: { min: 1, max: 1, signature: 'array' },
187
189
 
188
190
  // Table functions (used in FROM clause)
189
191
  UNNEST: { min: 1, max: 1, signature: 'array' },
@@ -198,6 +200,7 @@ export const FUNCTION_SIGNATURES = {
198
200
 
199
201
  // Aggregate functions
200
202
  COUNT: { min: 1, max: 1, signature: 'expression' },
203
+ COUNTIF: { min: 1, max: 1, signature: 'condition' },
201
204
  SUM: { min: 1, max: 1, signature: 'expression' },
202
205
  AVG: { min: 1, max: 1, signature: 'expression' },
203
206
  MIN: { min: 1, max: 1, signature: 'expression' },
@@ -48,13 +48,22 @@ export function validateScan({ table, hints, tables, positionStart, positionEnd
48
48
  /**
49
49
  * Throws if the expression references any column identifier. Used for
50
50
  * expressions that have no row scope (e.g. table function arguments in FROM).
51
+ * When `outerScope` is provided, identifiers that resolve to an outer query's
52
+ * alias (correlated reference) are allowed — they will be evaluated against
53
+ * the outer row at runtime.
51
54
  *
52
55
  * @param {ExprNode} expr
53
56
  * @param {string} context - context for the error message (e.g. function name)
57
+ * @param {string[]} [outerScope] - aliases of outer queries
54
58
  */
55
- export function validateNoIdentifiers(expr, context) {
59
+ export function validateNoIdentifiers(expr, context, outerScope) {
56
60
  if (!expr) return
57
61
  if (expr.type === 'identifier') {
62
+ if (outerScope?.length) {
63
+ // Correlated reference: prefix matches an outer alias, or unqualified
64
+ // (resolved against the outer row at runtime).
65
+ if (!expr.prefix || outerScope.includes(expr.prefix)) return
66
+ }
58
67
  const name = expr.prefix ? `${expr.prefix}.${expr.name}` : expr.name
59
68
  throw new ExecutionError({
60
69
  message: `${context} argument cannot reference column "${name}" — use JOIN ${context}(...) to reference columns from another table`,
@@ -63,31 +72,31 @@ export function validateNoIdentifiers(expr, context) {
63
72
  })
64
73
  }
65
74
  if (expr.type === 'binary') {
66
- validateNoIdentifiers(expr.left, context)
67
- validateNoIdentifiers(expr.right, context)
75
+ validateNoIdentifiers(expr.left, context, outerScope)
76
+ validateNoIdentifiers(expr.right, context, outerScope)
68
77
  } else if (expr.type === 'unary') {
69
- validateNoIdentifiers(expr.argument, context)
78
+ validateNoIdentifiers(expr.argument, context, outerScope)
70
79
  } else if (expr.type === 'function') {
71
80
  for (const arg of expr.args) {
72
- validateNoIdentifiers(arg, context)
81
+ validateNoIdentifiers(arg, context, outerScope)
73
82
  }
74
83
  } else if (expr.type === 'cast') {
75
- validateNoIdentifiers(expr.expr, context)
84
+ validateNoIdentifiers(expr.expr, context, outerScope)
76
85
  } else if (expr.type === 'in valuelist') {
77
- validateNoIdentifiers(expr.expr, context)
86
+ validateNoIdentifiers(expr.expr, context, outerScope)
78
87
  for (const val of expr.values) {
79
- validateNoIdentifiers(val, context)
88
+ validateNoIdentifiers(val, context, outerScope)
80
89
  }
81
90
  } else if (expr.type === 'in') {
82
91
  // LHS is in our scope; subquery is self-contained and planned separately.
83
- validateNoIdentifiers(expr.expr, context)
92
+ validateNoIdentifiers(expr.expr, context, outerScope)
84
93
  } else if (expr.type === 'case') {
85
- validateNoIdentifiers(expr.caseExpr, context)
94
+ validateNoIdentifiers(expr.caseExpr, context, outerScope)
86
95
  for (const w of expr.whenClauses) {
87
- validateNoIdentifiers(w.condition, context)
88
- validateNoIdentifiers(w.result, context)
96
+ validateNoIdentifiers(w.condition, context, outerScope)
97
+ validateNoIdentifiers(w.result, context, outerScope)
89
98
  }
90
- validateNoIdentifiers(expr.elseResult, context)
99
+ validateNoIdentifiers(expr.elseResult, context, outerScope)
91
100
  }
92
101
  // subquery / exists / not exists are self-contained — their identifiers
93
102
  // resolve to their own FROM and are validated when the subquery is planned.