squirreling 0.4.1 → 0.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Squirreling SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -38,10 +38,10 @@
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/node": "24.10.1",
41
- "@vitest/coverage-v8": "4.0.14",
41
+ "@vitest/coverage-v8": "4.0.15",
42
42
  "eslint": "9.39.1",
43
43
  "eslint-plugin-jsdoc": "61.4.1",
44
44
  "typescript": "5.9.3",
45
- "vitest": "4.0.14"
45
+ "vitest": "4.0.15"
46
46
  }
47
47
  }
@@ -4,7 +4,7 @@ import { defaultAggregateAlias, evaluateAggregate } from './aggregates.js'
4
4
  import { evaluateExpr } from './expression.js'
5
5
  import { evaluateHavingExpr } from './having.js'
6
6
  import { executeJoins } from './join.js'
7
- import { defaultDerivedAlias } from './utils.js'
7
+ import { compareForTerm, defaultDerivedAlias } from './utils.js'
8
8
 
9
9
  /**
10
10
  * @import { AsyncDataSource, ExecuteSqlOptions, ExprNode, OrderByItem, AsyncRow, SelectStatement, SqlPrimitive } from '../types.js'
@@ -89,31 +89,6 @@ async function stableRowKey(row) {
89
89
  return parts.join('|')
90
90
  }
91
91
 
92
- /**
93
- * Compares two SQL values for sorting
94
- *
95
- * @param {SqlPrimitive} a
96
- * @param {SqlPrimitive} b
97
- * @returns {number} negative if a < b, positive if a > b, 0 if equal
98
- */
99
- function compareValues(a, b) {
100
- if (a === b) return 0
101
- if (a == null) return -1
102
- if (b == null) return 1
103
-
104
- if (typeof a === 'number' && typeof b === 'number') {
105
- if (a < b) return -1
106
- if (a > b) return 1
107
- return 0
108
- }
109
-
110
- const aa = String(a)
111
- const bb = String(b)
112
- if (aa < bb) return -1
113
- if (aa > bb) return 1
114
- return 0
115
- }
116
-
117
92
  /**
118
93
  * Applies DISTINCT filtering to remove duplicate rows
119
94
  *
@@ -135,127 +110,89 @@ async function applyDistinct(rows, distinct) {
135
110
  }
136
111
  return result
137
112
  }
138
-
139
113
  /**
140
- * Applies ORDER BY sorting to RowSource array (before projection)
141
- *
142
- * @param {AsyncRow[]} rows - the input row sources
143
- * @param {OrderByItem[]} orderBy - the sort specifications
144
- * @param {Record<string, AsyncDataSource>} tables
145
- * @returns {Promise<AsyncRow[]>} the sorted row sources
146
- */
147
- async function sortRowSources(rows, orderBy, tables) {
148
- if (!orderBy.length) return rows
149
-
150
- // Pre-evaluate ORDER BY expressions for all rows
151
- /** @type {SqlPrimitive[][]} */
152
- const evaluatedValues = []
153
- for (const row of rows) {
154
- /** @type {SqlPrimitive[]} */
155
- const rowValues = []
156
- for (const term of orderBy) {
157
- const value = await evaluateExpr({ node: term.expr, row, tables })
158
- rowValues.push(value)
159
- }
160
- evaluatedValues.push(rowValues)
161
- }
162
-
163
- // Create index array and sort it
164
- const indices = rows.map((_, i) => i)
165
- indices.sort((aIdx, bIdx) => {
166
- for (let termIdx = 0; termIdx < orderBy.length; termIdx++) {
167
- const term = orderBy[termIdx]
168
- const dir = term.direction
169
- const av = evaluatedValues[aIdx][termIdx]
170
- const bv = evaluatedValues[bIdx][termIdx]
171
-
172
- // Handle NULLS FIRST / NULLS LAST
173
- const aIsNull = av == null
174
- const bIsNull = bv == null
175
-
176
- if (aIsNull || bIsNull) {
177
- if (aIsNull && bIsNull) continue
178
-
179
- const nullsFirst = term.nulls === 'LAST' ? false : true
180
-
181
- if (aIsNull) {
182
- return nullsFirst ? -1 : 1
183
- } else {
184
- return nullsFirst ? 1 : -1
185
- }
186
- }
187
-
188
- const cmp = compareValues(av, bv)
189
- if (cmp !== 0) {
190
- return dir === 'DESC' ? -cmp : cmp
191
- }
192
- }
193
- return 0
194
- })
195
-
196
- // Return sorted rows
197
- return indices.map(i => rows[i])
198
- }
199
-
200
- /**
201
- * Applies ORDER BY sorting to rows
114
+ * Applies ORDER BY sorting to rows using multi-pass lazy evaluation.
115
+ * Secondary ORDER BY columns are only evaluated for rows that tie on
116
+ * previous columns, reducing expensive cell evaluations.
202
117
  *
203
118
  * @param {AsyncRow[]} rows - the input rows
204
119
  * @param {OrderByItem[]} orderBy - the sort specifications
205
120
  * @param {Record<string, AsyncDataSource>} tables
206
121
  * @returns {Promise<AsyncRow[]>} the sorted rows
207
122
  */
208
- async function applyOrderBy(rows, orderBy, tables) {
123
+ async function sortRows(rows, orderBy, tables) {
209
124
  if (!orderBy.length) return rows
210
125
 
211
- // Pre-evaluate ORDER BY expressions for all rows
212
- /** @type {SqlPrimitive[][]} */
213
- const evaluatedValues = []
214
- for (const row of rows) {
215
- /** @type {SqlPrimitive[]} */
216
- const rowValues = []
217
- for (const term of orderBy) {
218
- const value = await evaluateExpr({ node: term.expr, row, tables })
219
- rowValues.push(value)
220
- }
221
- evaluatedValues.push(rowValues)
222
- }
223
-
224
- // Create index array and sort it
225
- const indices = rows.map((_, i) => i)
226
- indices.sort((aIdx, bIdx) => {
227
- for (let termIdx = 0; termIdx < orderBy.length; termIdx++) {
228
- const term = orderBy[termIdx]
229
- const dir = term.direction
230
- const av = evaluatedValues[aIdx][termIdx]
231
- const bv = evaluatedValues[bIdx][termIdx]
126
+ // Cache for evaluated values: evaluatedValues[rowIdx][colIdx]
127
+ /** @type {(SqlPrimitive | undefined)[][]} */
128
+ const evaluatedValues = rows.map(() => Array(orderBy.length))
232
129
 
233
- // Handle NULLS FIRST / NULLS LAST
234
- const aIsNull = av == null
235
- const bIsNull = bv == null
130
+ // Start with all indices in one group
131
+ /** @type {number[][]} */
132
+ let groups = [rows.map((_, i) => i)]
236
133
 
237
- if (aIsNull || bIsNull) {
238
- if (aIsNull && bIsNull) continue
134
+ // Process each ORDER BY column incrementally
135
+ for (let orderByIdx = 0; orderByIdx < orderBy.length; orderByIdx++) {
136
+ const term = orderBy[orderByIdx]
137
+ /** @type {number[][]} */
138
+ const nextGroups = []
239
139
 
240
- const nullsFirst = term.nulls === 'LAST' ? false : true
140
+ for (const group of groups) {
141
+ // Single-element groups don't need sorting or evaluation
142
+ if (group.length <= 1) {
143
+ nextGroups.push(group)
144
+ continue
145
+ }
241
146
 
242
- if (aIsNull) {
243
- return nullsFirst ? -1 : 1
244
- } else {
245
- return nullsFirst ? 1 : -1
147
+ // Evaluate this column for all rows in the group
148
+ for (const idx of group) {
149
+ if (evaluatedValues[idx][orderByIdx] === undefined) {
150
+ evaluatedValues[idx][orderByIdx] = await evaluateExpr({
151
+ node: term.expr,
152
+ row: rows[idx],
153
+ tables,
154
+ })
246
155
  }
247
156
  }
248
157
 
249
- const cmp = compareValues(av, bv)
250
- if (cmp !== 0) {
251
- return dir === 'DESC' ? -cmp : cmp
158
+ // Sort the group by this column
159
+ group.sort((aIdx, bIdx) => {
160
+ const av = evaluatedValues[aIdx][orderByIdx]
161
+ const bv = evaluatedValues[bIdx][orderByIdx]
162
+ return compareForTerm(av, bv, term)
163
+ })
164
+
165
+ // Split into sub-groups based on ties (for next column)
166
+ if (orderByIdx < orderBy.length - 1) {
167
+ /** @type {number[]} */
168
+ let currentSubGroup = [group[0]]
169
+ for (let i = 1; i < group.length; i++) {
170
+ const prevIdx = group[i - 1]
171
+ const currIdx = group[i]
172
+ const prevVal = evaluatedValues[prevIdx][orderByIdx]
173
+ const currVal = evaluatedValues[currIdx][orderByIdx]
174
+
175
+ if (compareForTerm(prevVal, currVal, term) === 0) {
176
+ // Same value, extend current sub-group
177
+ currentSubGroup.push(currIdx)
178
+ } else {
179
+ // Different value, start new sub-group
180
+ nextGroups.push(currentSubGroup)
181
+ currentSubGroup = [currIdx]
182
+ }
183
+ }
184
+ nextGroups.push(currentSubGroup)
185
+ } else {
186
+ // Last column, no need to split
187
+ nextGroups.push(group)
252
188
  }
253
189
  }
254
- return 0
255
- })
256
190
 
257
- // Return sorted rows
258
- return indices.map(i => rows[i])
191
+ groups = nextGroups
192
+ }
193
+
194
+ // Flatten groups to get final sorted indices
195
+ return groups.flat().map(i => rows[i])
259
196
  }
260
197
 
261
198
  /**
@@ -466,7 +403,7 @@ async function* evaluateBuffered(select, dataSource, tables, hasAggregate, useGr
466
403
  } else {
467
404
  // No grouping, simple projection
468
405
  // Sort before projection so ORDER BY can access columns not in SELECT
469
- const sorted = await sortRowSources(filtered, select.orderBy, tables)
406
+ const sorted = await sortRows(filtered, select.orderBy, tables)
470
407
 
471
408
  // OPTIMIZATION: For non-DISTINCT queries, apply OFFSET/LIMIT before projection
472
409
  // to avoid reading expensive cells for rows that won't be in the final result
@@ -498,7 +435,9 @@ async function* evaluateBuffered(select, dataSource, tables, hasAggregate, useGr
498
435
  projected = await applyDistinct(projected, select.distinct)
499
436
 
500
437
  // Step 5: ORDER BY (final sort for grouped queries)
501
- projected = await applyOrderBy(projected, select.orderBy, tables)
438
+ if (useGrouping) {
439
+ projected = await sortRows(projected, select.orderBy, tables)
440
+ }
502
441
 
503
442
  // Step 6: OFFSET and LIMIT
504
443
  // For non-DISTINCT, non-grouping queries, OFFSET/LIMIT was already applied before projection
@@ -110,21 +110,6 @@ export async function evaluateExpr({ node, row, tables }) {
110
110
  }
111
111
  }
112
112
 
113
- // BETWEEN and NOT BETWEEN
114
- if (node.type === 'between' || node.type === 'not between') {
115
- const expr = await evaluateExpr({ node: node.expr, row, tables })
116
- const lower = await evaluateExpr({ node: node.lower, row, tables })
117
- const upper = await evaluateExpr({ node: node.upper, row, tables })
118
-
119
- // If any value is NULL, return false (SQL behavior)
120
- if (expr == null || lower == null || upper == null) {
121
- return false
122
- }
123
-
124
- const isBetween = expr >= lower && expr <= upper
125
- return node.type === 'between' ? isBetween : !isBetween
126
- }
127
-
128
113
  // Function calls
129
114
  if (node.type === 'function') {
130
115
  const funcName = node.name.toUpperCase()
@@ -200,6 +185,11 @@ export async function evaluateExpr({ node, row, tables }) {
200
185
  return String(str).replaceAll(String(searchStr), String(replaceStr))
201
186
  }
202
187
 
188
+ if (funcName === 'RANDOM' || funcName === 'RAND') {
189
+ if (args.length !== 0) throw new Error(`${funcName} takes no arguments`)
190
+ return Math.random()
191
+ }
192
+
203
193
  throw new Error('Unsupported function ' + funcName)
204
194
  }
205
195
 
@@ -238,16 +228,7 @@ export async function evaluateExpr({ node, row, tables }) {
238
228
  }
239
229
  return false
240
230
  }
241
- if (node.type === 'not in valuelist') {
242
- const exprVal = await evaluateExpr({ node: node.expr, row, tables })
243
- for (const valueNode of node.values) {
244
- const val = await evaluateExpr({ node: valueNode, row, tables })
245
- if (exprVal === val) return false
246
- }
247
- return true
248
- }
249
-
250
- // IN and NOT IN with subqueries
231
+ // IN with subqueries
251
232
  if (node.type === 'in') {
252
233
  const exprVal = await evaluateExpr({ node: node.expr, row, tables })
253
234
  const results = executeSelect(node.subquery, tables)
@@ -260,18 +241,6 @@ export async function evaluateExpr({ node, row, tables }) {
260
241
  }
261
242
  return values.includes(exprVal)
262
243
  }
263
- if (node.type === 'not in') {
264
- const exprVal = await evaluateExpr({ node: node.expr, row, tables })
265
- const results = executeSelect(node.subquery, tables)
266
- /** @type {SqlPrimitive[]} */
267
- const values = []
268
- for await (const resRow of results) {
269
- const firstKey = Object.keys(resRow)[0]
270
- const val = await resRow[firstKey]()
271
- values.push(val)
272
- }
273
- return !values.includes(exprVal)
274
- }
275
244
 
276
245
  // EXISTS and NOT EXISTS with subqueries
277
246
  if (node.type === 'exists') {
@@ -76,20 +76,6 @@ export async function evaluateHavingExpr(expr, row, group, tables) {
76
76
  }
77
77
  }
78
78
 
79
- if (expr.type === 'between' || expr.type === 'not between') {
80
- const exprVal = await evaluateHavingValue(expr.expr, context, group, tables)
81
- const lower = await evaluateHavingValue(expr.lower, context, group, tables)
82
- const upper = await evaluateHavingValue(expr.upper, context, group, tables)
83
-
84
- // If any value is NULL, return false (SQL behavior)
85
- if (exprVal == null || lower == null || upper == null) {
86
- return false
87
- }
88
-
89
- const isBetween = exprVal >= lower && exprVal <= upper
90
- return expr.type === 'between' ? isBetween : !isBetween
91
- }
92
-
93
79
  // For other expression types, use the context row
94
80
  return Boolean(await evaluateExpr({ node: expr, row: context, tables }))
95
81
  }
@@ -112,7 +98,7 @@ function evaluateHavingValue(expr, context, group, tables) {
112
98
  }
113
99
 
114
100
  // For binary expressions, we need to use evaluateHavingExpr to properly handle aggregates
115
- if (expr.type === 'binary' || expr.type === 'unary' || expr.type === 'between' || expr.type === 'not between') {
101
+ if (expr.type === 'binary' || expr.type === 'unary') {
116
102
  return evaluateHavingExpr(expr, context, group, tables)
117
103
  }
118
104
 
@@ -1,7 +1,44 @@
1
+ /**
2
+ * @import {AsyncRow, ExprNode, OrderByItem, SqlPrimitive} from '../types.js'
3
+ */
4
+
5
+ /**
6
+ * Compares two values for a single ORDER BY term, handling nulls and direction
7
+ *
8
+ * @param {SqlPrimitive} a
9
+ * @param {SqlPrimitive} b
10
+ * @param {OrderByItem} term
11
+ * @returns {number} comparison result
12
+ */
13
+ export function compareForTerm(a, b, term) {
14
+ const aIsNull = a == null
15
+ const bIsNull = b == null
16
+
17
+ if (aIsNull || bIsNull) {
18
+ if (aIsNull && bIsNull) return 0
19
+ const nullsFirst = term.nulls !== 'LAST'
20
+ if (aIsNull) return nullsFirst ? -1 : 1
21
+ return nullsFirst ? 1 : -1
22
+ }
23
+
24
+ // Compare non-null values
25
+ if (a === b) return 0
26
+
27
+ let cmp
28
+ if (typeof a === 'number' && typeof b === 'number') {
29
+ cmp = a < b ? -1 : a > b ? 1 : 0
30
+ } else {
31
+ const aa = String(a)
32
+ const bb = String(b)
33
+ cmp = aa < bb ? -1 : aa > bb ? 1 : 0
34
+ }
35
+
36
+ return term.direction === 'DESC' ? -cmp : cmp
37
+ }
38
+
1
39
  /**
2
40
  * Collects and materialize all results from an async row generator into an array
3
41
  *
4
- * @import {AsyncRow, ExprNode, SqlPrimitive} from '../types.js'
5
42
  * @param {AsyncGenerator<AsyncRow>} asyncRows
6
43
  * @returns {Promise<Record<string, SqlPrimitive>[]>} array of all yielded values
7
44
  */
package/src/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { AsyncRow, ExecuteSqlOptions, SelectStatement, SqlPrimitive } from './types.js'
2
+ export type { AsyncDataSource, AsyncRow, SqlPrimitive } from './types.js'
2
3
 
3
4
  /**
4
5
  * Executes a SQL SELECT query against an array of data rows
@@ -301,7 +301,26 @@ function parseComparison(c) {
301
301
  }
302
302
  }
303
303
 
304
- // LIKE
304
+ // [NOT] LIKE
305
+ if (tok.type === 'keyword' && tok.value === 'NOT') {
306
+ const nextTok = c.peek(1)
307
+ if (nextTok.type === 'keyword' && nextTok.value === 'LIKE') {
308
+ c.consume() // NOT
309
+ c.consume() // LIKE
310
+ const right = parsePrimary(c)
311
+ return {
312
+ type: 'unary',
313
+ op: 'NOT',
314
+ argument: {
315
+ type: 'binary',
316
+ op: 'LIKE',
317
+ left,
318
+ right,
319
+ },
320
+ }
321
+ }
322
+ }
323
+
305
324
  if (tok.type === 'keyword' && tok.value === 'LIKE') {
306
325
  c.consume()
307
326
  const right = parsePrimary(c)
@@ -313,7 +332,7 @@ function parseComparison(c) {
313
332
  }
314
333
  }
315
334
 
316
- // [NOT] BETWEEN
335
+ // [NOT] BETWEEN - convert to range comparison
317
336
  if (tok.type === 'keyword' && tok.value === 'NOT') {
318
337
  const nextTok = c.peek(1)
319
338
  if (nextTok.type === 'keyword' && nextTok.value === 'BETWEEN') {
@@ -322,11 +341,12 @@ function parseComparison(c) {
322
341
  const lower = parsePrimary(c)
323
342
  c.expect('keyword', 'AND')
324
343
  const upper = parsePrimary(c)
344
+ // NOT BETWEEN -> expr < lower OR expr > upper
325
345
  return {
326
- type: 'not between',
327
- expr: left,
328
- lower,
329
- upper,
346
+ type: 'binary',
347
+ op: 'OR',
348
+ left: { type: 'binary', op: '<', left, right: lower },
349
+ right: { type: 'binary', op: '>', left, right: upper },
330
350
  }
331
351
  }
332
352
  }
@@ -336,11 +356,12 @@ function parseComparison(c) {
336
356
  const lower = parsePrimary(c)
337
357
  c.expect('keyword', 'AND')
338
358
  const upper = parsePrimary(c)
359
+ // BETWEEN -> expr >= lower AND expr <= upper
339
360
  return {
340
- type: 'between',
341
- expr: left,
342
- lower,
343
- upper,
361
+ type: 'binary',
362
+ op: 'AND',
363
+ left: { type: 'binary', op: '>=', left, right: lower },
364
+ right: { type: 'binary', op: '<=', left, right: upper },
344
365
  }
345
366
  }
346
367
 
@@ -365,9 +386,13 @@ function parseComparison(c) {
365
386
  }
366
387
  const subquery = c.parseSubquery()
367
388
  return {
368
- type: 'not in',
369
- expr: left,
370
- subquery,
389
+ type: 'unary',
390
+ op: 'NOT',
391
+ argument: {
392
+ type: 'in',
393
+ expr: left,
394
+ subquery,
395
+ },
371
396
  }
372
397
  } else {
373
398
  // Parse list of values - we handle the parens
@@ -380,9 +405,13 @@ function parseComparison(c) {
380
405
  }
381
406
  c.expect('paren', ')')
382
407
  return {
383
- type: 'not in valuelist',
384
- expr: left,
385
- values,
408
+ type: 'unary',
409
+ op: 'NOT',
410
+ argument: {
411
+ type: 'in valuelist',
412
+ expr: left,
413
+ values,
414
+ },
386
415
  }
387
416
  }
388
417
  }
package/src/types.d.ts CHANGED
@@ -90,21 +90,14 @@ export interface CastNode {
90
90
  toType: string
91
91
  }
92
92
 
93
- export interface BetweenNode {
94
- type: 'between' | 'not between'
95
- expr: ExprNode
96
- lower: ExprNode
97
- upper: ExprNode
98
- }
99
-
100
93
  export interface InSubqueryNode {
101
- type: 'in' | 'not in'
94
+ type: 'in'
102
95
  expr: ExprNode
103
96
  subquery: SelectStatement
104
97
  }
105
98
 
106
99
  export interface InValuesNode {
107
- type: 'in valuelist' | 'not in valuelist'
100
+ type: 'in valuelist'
108
101
  expr: ExprNode
109
102
  values: ExprNode[]
110
103
  }
@@ -138,7 +131,6 @@ export type ExprNode =
138
131
  | BinaryNode
139
132
  | FunctionNode
140
133
  | CastNode
141
- | BetweenNode
142
134
  | InSubqueryNode
143
135
  | InValuesNode
144
136
  | ExistsNode
package/src/validation.js CHANGED
@@ -13,5 +13,5 @@ export function isAggregateFunc(name) {
13
13
  * @returns {name is StringFunc}
14
14
  */
15
15
  export function isStringFunc(name) {
16
- return ['UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'SUBSTRING', 'SUBSTR', 'TRIM', 'REPLACE'].includes(name)
16
+ return ['UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'SUBSTRING', 'SUBSTR', 'TRIM', 'REPLACE', 'RANDOM', 'RAND'].includes(name)
17
17
  }