squirreling 0.9.1 → 0.9.3
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 +9 -6
- package/package.json +3 -3
- package/src/backend/dataSource.js +3 -2
- package/src/execute/execute.js +44 -4
- package/src/expression/alias.js +1 -1
- package/src/expression/evaluate.js +37 -7
- package/src/expression/regexp.js +2 -2
- package/src/expression/spatial.js +1471 -0
- package/src/expression/strings.js +2 -2
- package/src/parse/expression.js +2 -0
- package/src/parse/functions.js +2 -3
- package/src/parseErrors.js +2 -2
- package/src/plan/columns.js +18 -38
- package/src/plan/plan.js +48 -19
- package/src/plan/types.d.ts +9 -1
- package/src/types.d.ts +22 -0
- package/src/validation.js +39 -2
- package/src/validationErrors.js +17 -1
package/README.md
CHANGED
|
@@ -47,8 +47,8 @@ const asyncRows: AsyncIterable<AsyncRow> = executeSql({
|
|
|
47
47
|
})
|
|
48
48
|
|
|
49
49
|
// Process rows as they arrive (streaming)
|
|
50
|
-
for await (const {
|
|
51
|
-
console.log(`User id=${await id()}, name=${await name()}`)
|
|
50
|
+
for await (const { cells } of asyncRows) {
|
|
51
|
+
console.log(`User id=${await cells.id()}, name=${await cells.name()}`)
|
|
52
52
|
}
|
|
53
53
|
```
|
|
54
54
|
|
|
@@ -95,7 +95,7 @@ interface AsyncDataSource {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
interface ScanOptions {
|
|
98
|
-
columns?: string[]
|
|
98
|
+
columns?: string[] // columns to scan (undefined means all)
|
|
99
99
|
where?: ExprNode
|
|
100
100
|
limit?: number
|
|
101
101
|
offset?: number
|
|
@@ -128,11 +128,12 @@ const customSource: AsyncDataSource = {
|
|
|
128
128
|
|
|
129
129
|
Squirreling mostly follows the SQL standard. The following features are supported:
|
|
130
130
|
|
|
131
|
-
- `SELECT` statements with `WHERE`, `ORDER BY`, `LIMIT`, `OFFSET`
|
|
131
|
+
- `SELECT` statements with `DISTINCT`, `WHERE`, `ORDER BY`, `LIMIT`, `OFFSET`
|
|
132
132
|
- `WITH` clause for Common Table Expressions (CTEs)
|
|
133
133
|
- Subqueries in `SELECT`, `FROM`, and `WHERE` clauses
|
|
134
|
-
- `JOIN` operations: `INNER JOIN`, `LEFT JOIN`, `RIGHT JOIN`, `FULL JOIN`, `POSITIONAL JOIN`
|
|
134
|
+
- `JOIN` operations: `INNER JOIN`, `LEFT JOIN`, `RIGHT JOIN`, `FULL JOIN`, `CROSS JOIN`, `POSITIONAL JOIN`
|
|
135
135
|
- `GROUP BY` and `HAVING` clauses
|
|
136
|
+
- Expressions: `CASE`, `CAST`, `BETWEEN`, `IN`, `LIKE`, `IS NULL`, `IS NOT NULL`
|
|
136
137
|
|
|
137
138
|
### Quoting
|
|
138
139
|
|
|
@@ -142,12 +143,14 @@ Squirreling mostly follows the SQL standard. The following features are supporte
|
|
|
142
143
|
|
|
143
144
|
### Functions
|
|
144
145
|
|
|
145
|
-
- Aggregate: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `JSON_ARRAYAGG`
|
|
146
|
+
- Aggregate: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `STDDEV_POP`, `STDDEV_SAMP`, `JSON_ARRAYAGG`
|
|
146
147
|
- String: `CONCAT`, `SUBSTRING`, `REPLACE`, `LENGTH`, `UPPER`, `LOWER`, `TRIM`, `LEFT`, `RIGHT`, `INSTR`
|
|
147
148
|
- Math: `ABS`, `SIGN`, `CEIL`, `FLOOR`, `ROUND`, `MOD`, `RAND`, `RANDOM`, `LN`, `LOG10`, `EXP`, `POWER`, `SQRT`
|
|
148
149
|
- Trig: `SIN`, `COS`, `TAN`, `COT`, `ASIN`, `ACOS`, `ATAN`, `ATAN2`, `DEGREES`, `RADIANS`, `PI`
|
|
149
150
|
- Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `INTERVAL`
|
|
150
151
|
- Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_OBJECT`
|
|
152
|
+
- Array: `ARRAY_LENGTH`, `ARRAY_POSITION`, `ARRAY_SORT`, `CARDINALITY`
|
|
151
153
|
- Regex: `REGEXP_SUBSTR`, `REGEXP_REPLACE`
|
|
154
|
+
- 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`
|
|
152
155
|
- Conditional: `COALESCE`, `NULLIF`
|
|
153
156
|
- User-defined functions (UDFs)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squirreling",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"description": "Squirreling Async SQL Engine",
|
|
5
5
|
"author": "Hyperparam",
|
|
6
6
|
"homepage": "https://hyperparam.app",
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"test": "vitest run"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@types/node": "25.
|
|
40
|
+
"@types/node": "25.3.0",
|
|
41
41
|
"@vitest/coverage-v8": "4.0.18",
|
|
42
42
|
"eslint": "9.39.2",
|
|
43
|
-
"eslint-plugin-jsdoc": "62.
|
|
43
|
+
"eslint-plugin-jsdoc": "62.7.1",
|
|
44
44
|
"typescript": "5.9.3",
|
|
45
45
|
"vitest": "4.0.18"
|
|
46
46
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @import { AsyncCells, AsyncDataSource, AsyncRow,
|
|
2
|
+
* @import { AsyncCells, AsyncDataSource, AsyncRow, SqlPrimitive } from '../types.js'
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* @param {Record<string, SqlPrimitive>} obj - the plain object
|
|
9
9
|
* @returns {AsyncRow} a row accessor interface
|
|
10
10
|
*/
|
|
11
|
-
function asyncRow(obj) {
|
|
11
|
+
export function asyncRow(obj) {
|
|
12
12
|
/** @type {AsyncCells} */
|
|
13
13
|
const cells = {}
|
|
14
14
|
for (const [key, value] of Object.entries(obj)) {
|
|
@@ -25,6 +25,7 @@ function asyncRow(obj) {
|
|
|
25
25
|
*/
|
|
26
26
|
export function memorySource(data) {
|
|
27
27
|
return {
|
|
28
|
+
numRows: data.length,
|
|
28
29
|
scan({ where, limit, offset, signal }) {
|
|
29
30
|
// Only apply offset and limit if no where clause
|
|
30
31
|
const start = !where ? offset ?? 0 : 0
|
package/src/execute/execute.js
CHANGED
|
@@ -11,7 +11,7 @@ import { stableRowKey } from './utils.js'
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* @import { AsyncCells, AsyncDataSource, AsyncRow, ExecuteContext, ExecuteSqlOptions, ExprNode, SelectStatement } from '../types.js'
|
|
14
|
-
* @import { DistinctNode, FilterNode, LimitNode, ProjectNode, QueryPlan, ScanNode } from '../plan/types.js'
|
|
14
|
+
* @import { CountNode, DistinctNode, FilterNode, LimitNode, ProjectNode, QueryPlan, ScanNode } from '../plan/types.js'
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -61,6 +61,8 @@ export async function* executeSelect({ select, context }) {
|
|
|
61
61
|
export async function* executePlan({ plan, context }) {
|
|
62
62
|
if (plan.type === 'Scan') {
|
|
63
63
|
yield* executeScan(plan, context)
|
|
64
|
+
} else if (plan.type === 'Count') {
|
|
65
|
+
yield* executeCount(plan, context)
|
|
64
66
|
} else if (plan.type === 'Filter') {
|
|
65
67
|
yield* executeFilter(plan, context)
|
|
66
68
|
} else if (plan.type === 'Project') {
|
|
@@ -105,15 +107,15 @@ async function* executeScan(plan, context) {
|
|
|
105
107
|
const { rows, appliedWhere, appliedLimitOffset } = scanResult
|
|
106
108
|
|
|
107
109
|
// Applied limit/offset without applied where is invalid
|
|
108
|
-
const hasLimitOffset = plan.hints
|
|
109
|
-
if (!appliedWhere && appliedLimitOffset && plan.hints
|
|
110
|
+
const hasLimitOffset = plan.hints.limit !== undefined || plan.hints.offset // 0 offset is noop
|
|
111
|
+
if (!appliedWhere && appliedLimitOffset && plan.hints.where && hasLimitOffset) {
|
|
110
112
|
throw new Error(`Data source "${plan.table}" applied limit/offset without applying where`)
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
let result = rows
|
|
114
116
|
|
|
115
117
|
// Apply WHERE if data source did not
|
|
116
|
-
if (!appliedWhere && plan.hints
|
|
118
|
+
if (!appliedWhere && plan.hints.where) {
|
|
117
119
|
result = filterRows(result, plan.hints.where, context)
|
|
118
120
|
}
|
|
119
121
|
|
|
@@ -125,6 +127,44 @@ async function* executeScan(plan, context) {
|
|
|
125
127
|
yield* result
|
|
126
128
|
}
|
|
127
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Executes a Count node using numRows when available, falling back to scan
|
|
132
|
+
*
|
|
133
|
+
* @param {CountNode} plan
|
|
134
|
+
* @param {ExecuteContext} context
|
|
135
|
+
* @yields {AsyncRow}
|
|
136
|
+
*/
|
|
137
|
+
async function* executeCount(plan, { tables, signal }) {
|
|
138
|
+
const dataSource = tables[plan.table]
|
|
139
|
+
if (dataSource === undefined) {
|
|
140
|
+
throw tableNotFoundError({ tableName: plan.table })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Use source numRows if available
|
|
144
|
+
let count = dataSource.numRows
|
|
145
|
+
if (dataSource.numRows === undefined) {
|
|
146
|
+
// Fall back to counting rows via scan
|
|
147
|
+
count = 0
|
|
148
|
+
const { rows } = dataSource.scan({ signal })
|
|
149
|
+
// eslint-disable-next-line no-unused-vars
|
|
150
|
+
for await (const _ of rows) {
|
|
151
|
+
if (signal?.aborted) return
|
|
152
|
+
count++
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** @type {string[]} */
|
|
157
|
+
const columns = []
|
|
158
|
+
/** @type {AsyncCells} */
|
|
159
|
+
const cells = {}
|
|
160
|
+
for (const col of plan.columns) {
|
|
161
|
+
const alias = col.alias ?? derivedAlias(col.expr)
|
|
162
|
+
columns.push(alias)
|
|
163
|
+
cells[alias] = () => Promise.resolve(count)
|
|
164
|
+
}
|
|
165
|
+
yield { columns, cells }
|
|
166
|
+
}
|
|
167
|
+
|
|
128
168
|
/**
|
|
129
169
|
* Filters rows by a condition
|
|
130
170
|
*
|
package/src/expression/alias.js
CHANGED
|
@@ -30,7 +30,7 @@ export function derivedAlias(expr) {
|
|
|
30
30
|
}
|
|
31
31
|
if (expr.type === 'function') {
|
|
32
32
|
// Handle aggregate functions with star (COUNT(*) -> count_all)
|
|
33
|
-
if (expr.args.length === 1 && expr.args[0].type === '
|
|
33
|
+
if (expr.args.length === 1 && expr.args[0].type === 'star') {
|
|
34
34
|
return expr.name.toLowerCase() + '_all'
|
|
35
35
|
}
|
|
36
36
|
return expr.name.toLowerCase() + '_' + expr.args.map(derivedAlias).join('_')
|
|
@@ -2,13 +2,14 @@ import { executeSelect } from '../execute/execute.js'
|
|
|
2
2
|
import { stringify } from '../execute/utils.js'
|
|
3
3
|
import { columnNotFoundError, invalidContextError } from '../executionErrors.js'
|
|
4
4
|
import { unknownFunctionError } from '../parseErrors.js'
|
|
5
|
-
import { isAggregateFunc, isMathFunc, isRegexpFunc, isStringFunc } from '../validation.js'
|
|
5
|
+
import { isAggregateFunc, isMathFunc, isRegexpFunc, isSpatialFunc, isStringFunc } from '../validation.js'
|
|
6
6
|
import { aggregateError, argValueError, castError } from '../validationErrors.js'
|
|
7
7
|
import { derivedAlias } from './alias.js'
|
|
8
8
|
import { applyBinaryOp } from './binary.js'
|
|
9
9
|
import { applyIntervalToDate } from './date.js'
|
|
10
10
|
import { evaluateMathFunc } from './math.js'
|
|
11
11
|
import { evaluateRegexpFunc } from './regexp.js'
|
|
12
|
+
import { evaluateSpatialFunc } from './spatial.js'
|
|
12
13
|
import { evaluateStringFunc } from './strings.js'
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -127,14 +128,13 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
127
128
|
}
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
// Handle COUNT(*) special case
|
|
131
|
-
if (node.args.length === 1 && node.args[0].type === 'identifier' && funcName === 'COUNT' && node.args[0].name === '*') {
|
|
132
|
-
return filteredRows.length
|
|
133
|
-
}
|
|
134
|
-
|
|
135
131
|
const argNode = node.args[0]
|
|
136
|
-
|
|
137
132
|
if (funcName === 'COUNT') {
|
|
133
|
+
// COUNT(*) special case
|
|
134
|
+
if (argNode.type === 'star') {
|
|
135
|
+
return filteredRows.length
|
|
136
|
+
}
|
|
137
|
+
|
|
138
138
|
if (node.distinct) {
|
|
139
139
|
const seen = new Set()
|
|
140
140
|
for (const row of filteredRows) {
|
|
@@ -251,6 +251,10 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
251
251
|
return evaluateMathFunc({ funcName, args })
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
if (isSpatialFunc(funcName)) {
|
|
255
|
+
return evaluateSpatialFunc({ funcName, args })
|
|
256
|
+
}
|
|
257
|
+
|
|
254
258
|
if (funcName === 'COALESCE') {
|
|
255
259
|
// Short-circuit: evaluate args one at a time, return first non-null
|
|
256
260
|
for (const arg of node.args) {
|
|
@@ -309,6 +313,32 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
309
313
|
return result
|
|
310
314
|
}
|
|
311
315
|
|
|
316
|
+
if (funcName === 'ARRAY_LENGTH' || funcName === 'CARDINALITY') {
|
|
317
|
+
const arr = args[0]
|
|
318
|
+
if (!Array.isArray(arr)) return null
|
|
319
|
+
return arr.length
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (funcName === 'ARRAY_POSITION') {
|
|
323
|
+
const [arr, target] = args
|
|
324
|
+
if (!Array.isArray(arr)) return null
|
|
325
|
+
const index = arr.indexOf(target)
|
|
326
|
+
return index === -1 ? null : index + 1
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (funcName === 'ARRAY_SORT') {
|
|
330
|
+
const arr = args[0]
|
|
331
|
+
if (!Array.isArray(arr)) return null
|
|
332
|
+
return [...arr].sort((a, b) => {
|
|
333
|
+
if (a == null && b == null) return 0
|
|
334
|
+
if (a == null) return 1
|
|
335
|
+
if (b == null) return -1
|
|
336
|
+
if (a < b) return -1
|
|
337
|
+
if (a > b) return 1
|
|
338
|
+
return 0
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
|
|
312
342
|
if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY') {
|
|
313
343
|
let jsonArg = args[0]
|
|
314
344
|
const pathArg = args[1]
|
package/src/expression/regexp.js
CHANGED
|
@@ -10,8 +10,8 @@ import { argValueError } from '../validationErrors.js'
|
|
|
10
10
|
* @param {Object} options
|
|
11
11
|
* @param {string} options.funcName - Uppercase function name
|
|
12
12
|
* @param {SqlPrimitive[]} options.args - Function arguments
|
|
13
|
-
* @param {number}
|
|
14
|
-
* @param {number}
|
|
13
|
+
* @param {number} options.positionStart - Start position in SQL string for error reporting
|
|
14
|
+
* @param {number} options.positionEnd - End position in SQL string for error reporting
|
|
15
15
|
* @param {number} [options.rowIndex] - Row number for error reporting
|
|
16
16
|
* @returns {SqlPrimitive}
|
|
17
17
|
*/
|