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 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 { id, name } of asyncRows) {
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.1",
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.2.3",
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.6.0",
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, ScanOptions, ScanResults, SqlPrimitive } from '../types.js'
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
@@ -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?.limit !== undefined || plan.hints?.offset // 0 offset is noop
109
- if (!appliedWhere && appliedLimitOffset && plan.hints?.where && hasLimitOffset) {
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?.where) {
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
  *
@@ -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 === 'identifier' && expr.args[0].name === '*') {
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]
@@ -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} [options.positionStart] - Start position in SQL string for error reporting
14
- * @param {number} [options.positionEnd] - End position in SQL string for error reporting
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
  */