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 +2 -2
- package/package.json +2 -2
- package/src/expression/evaluate.js +35 -8
- package/src/plan/columns.js +32 -0
- package/src/plan/plan.js +1 -1
- package/src/types.d.ts +1 -1
- package/src/validation/functions.js +4 -1
- package/src/validation/tables.js +22 -13
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.
|
|
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.
|
|
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
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
const
|
|
659
|
-
|
|
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
|
package/src/plan/columns.js
CHANGED
|
@@ -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' },
|
package/src/validation/tables.js
CHANGED
|
@@ -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.
|