squirreling 0.4.0 → 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.
@@ -0,0 +1,357 @@
1
+ import { evaluateExpr } from './expression.js'
2
+
3
+ /**
4
+ * @import { AsyncRow, AsyncDataSource, JoinClause, ExprNode } from '../types.js'
5
+ */
6
+
7
+ /**
8
+ * Executes JOIN operations against a base data source
9
+ *
10
+ * @param {AsyncDataSource} leftSource - the left side of the join (FROM table)
11
+ * @param {JoinClause[]} joins - array of join clauses to execute
12
+ * @param {string} leftTableName - name of the left table (for column prefixing)
13
+ * @param {Record<string, AsyncDataSource>} tables - all available tables
14
+ * @returns {Promise<AsyncDataSource>} data source yielding joined rows
15
+ */
16
+ export async function executeJoins(leftSource, joins, leftTableName, tables) {
17
+ let currentLeftTable = leftTableName
18
+
19
+ // Single join optimization: stream left rows without buffering
20
+ if (joins.length === 1) {
21
+ const join = joins[0]
22
+ const rightSource = tables[join.table]
23
+ if (rightSource === undefined) {
24
+ throw new Error(`Table "${join.table}" not found`)
25
+ }
26
+
27
+ // Buffer right rows for hash index (required for hash join)
28
+ /** @type {AsyncRow[]} */
29
+ const rightRows = []
30
+ for await (const row of rightSource.getRows()) {
31
+ rightRows.push(row)
32
+ }
33
+
34
+ // Use alias for column prefixing if present
35
+ const rightTableName = join.alias ?? join.table
36
+
37
+ // Return streaming data source - left rows stream through without buffering
38
+ return {
39
+ async *getRows() {
40
+ yield* hashJoin({
41
+ leftRows: leftSource.getRows(), // Stream directly, not buffered
42
+ rightRows,
43
+ join,
44
+ leftTable: currentLeftTable,
45
+ rightTable: rightTableName,
46
+ tables,
47
+ })
48
+ },
49
+ }
50
+ }
51
+
52
+ // Multiple joins: buffer intermediate results, stream final join
53
+ /** @type {AsyncRow[]} */
54
+ let leftRows = []
55
+ for await (const row of leftSource.getRows()) {
56
+ leftRows.push(row)
57
+ }
58
+
59
+ // Process all but the last join, buffering intermediate results
60
+ for (let i = 0; i < joins.length - 1; i++) {
61
+ const join = joins[i]
62
+ const rightSource = tables[join.table]
63
+ if (rightSource === undefined) {
64
+ throw new Error(`Table "${join.table}" not found`)
65
+ }
66
+
67
+ /** @type {AsyncRow[]} */
68
+ const rightRows = []
69
+ for await (const row of rightSource.getRows()) {
70
+ rightRows.push(row)
71
+ }
72
+
73
+ // Use alias for column prefixing if present
74
+ const rightTableName = join.alias ?? join.table
75
+
76
+ // Collect intermediate results into array for next join
77
+ /** @type {AsyncRow[]} */
78
+ const newLeftRows = []
79
+ const joined = hashJoin({
80
+ leftRows,
81
+ rightRows,
82
+ join,
83
+ leftTable: currentLeftTable,
84
+ rightTable: rightTableName,
85
+ tables,
86
+ })
87
+ for await (const row of joined) {
88
+ newLeftRows.push(row)
89
+ }
90
+ leftRows = newLeftRows
91
+
92
+ // After join, the "left" table for the next join includes all joined tables
93
+ currentLeftTable = `${currentLeftTable}_${rightTableName}`
94
+ }
95
+
96
+ // Final join: stream the results
97
+ const lastJoin = joins[joins.length - 1]
98
+ const rightSource = tables[lastJoin.table]
99
+ if (rightSource === undefined) {
100
+ throw new Error(`Table "${lastJoin.table}" not found`)
101
+ }
102
+
103
+ /** @type {AsyncRow[]} */
104
+ const rightRows = []
105
+ for await (const row of rightSource.getRows()) {
106
+ rightRows.push(row)
107
+ }
108
+
109
+ // Use alias for column prefixing if present
110
+ const lastRightTableName = lastJoin.alias ?? lastJoin.table
111
+
112
+ return {
113
+ async *getRows() {
114
+ yield* hashJoin({
115
+ leftRows,
116
+ rightRows,
117
+ join: lastJoin,
118
+ leftTable: currentLeftTable,
119
+ rightTable: lastRightTableName,
120
+ tables,
121
+ })
122
+ },
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Checks if an expression references a specific table.
128
+ * Returns true if the expression is an identifier prefixed with the table name.
129
+ *
130
+ * @param {ExprNode} expr
131
+ * @param {string} tableName
132
+ * @returns {boolean}
133
+ */
134
+ function exprReferencesTable(expr, tableName) {
135
+ return expr.type === 'identifier' && expr.name.startsWith(`${tableName}.`)
136
+ }
137
+
138
+ /**
139
+ * Extracts the join key expressions from an ON condition.
140
+ * Handles both `left.col = right.col` and `right.col = left.col` orderings.
141
+ *
142
+ * @param {ExprNode} onCondition
143
+ * @param {string} leftTable
144
+ * @param {string} rightTable
145
+ * @returns {{ leftKey: ExprNode, rightKey: ExprNode } | undefined}
146
+ */
147
+ function extractJoinKeys(onCondition, leftTable, rightTable) {
148
+ if (onCondition.type === 'binary' && onCondition.op === '=') {
149
+ const { left, right } = onCondition
150
+
151
+ // Check if keys are swapped (right table referenced in left position)
152
+ const leftRefsRight = exprReferencesTable(left, rightTable)
153
+ const rightRefsLeft = exprReferencesTable(right, leftTable)
154
+
155
+ if (leftRefsRight && rightRefsLeft) {
156
+ // Keys are swapped, return them in correct order
157
+ return { leftKey: right, rightKey: left }
158
+ }
159
+
160
+ // Default: assume left operand is for left table
161
+ return { leftKey: left, rightKey: right }
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Creates a NULL-filled row with the given column names
167
+ *
168
+ * @param {string[]} columnNames
169
+ * @returns {AsyncRow}
170
+ */
171
+ function createNullRow(columnNames) {
172
+ /** @type {AsyncRow} */
173
+ const row = {}
174
+ for (const col of columnNames) {
175
+ row[col] = () => Promise.resolve(null)
176
+ }
177
+ return row
178
+ }
179
+
180
+ /**
181
+ * Merges two rows into one, prefixing columns with table names
182
+ *
183
+ * @param {AsyncRow} leftRow
184
+ * @param {AsyncRow} rightRow
185
+ * @param {string} leftTable
186
+ * @param {string} rightTable
187
+ * @returns {AsyncRow}
188
+ */
189
+ function mergeRows(leftRow, rightRow, leftTable, rightTable) {
190
+ /** @type {AsyncRow} */
191
+ const merged = {}
192
+
193
+ // Add left table columns with prefix
194
+ for (const [key, cell] of Object.entries(leftRow)) {
195
+ // Skip already-prefixed keys (from previous joins)
196
+ if (!key.includes('.')) {
197
+ merged[`${leftTable}.${key}`] = cell
198
+ } else {
199
+ merged[key] = cell
200
+ }
201
+ // Also keep unqualified name for convenience (may be overwritten if ambiguous)
202
+ merged[key] = cell
203
+ }
204
+
205
+ // Add right table columns with prefix
206
+ for (const [key, cell] of Object.entries(rightRow)) {
207
+ if (!key.includes('.')) {
208
+ merged[`${rightTable}.${key}`] = cell
209
+ } else {
210
+ merged[key] = cell
211
+ }
212
+ // Unqualified name (overwrites if same name exists in left table)
213
+ merged[key] = cell
214
+ }
215
+
216
+ return merged
217
+ }
218
+
219
+ /**
220
+ * Performs a hash join between left and right row sets (streaming).
221
+ * Yields rows as they are found instead of buffering all results.
222
+ *
223
+ * @param {Object} params
224
+ * @param {AsyncIterable<AsyncRow>|AsyncRow[]} params.leftRows - rows from left table (can stream)
225
+ * @param {AsyncRow[]} params.rightRows - rows from right table (must be buffered for hash index)
226
+ * @param {JoinClause} params.join - join specification
227
+ * @param {string} params.leftTable - name of left table (for column prefixing)
228
+ * @param {string} params.rightTable - name of right table (for column prefixing, may be alias)
229
+ * @param {Record<string, AsyncDataSource>} params.tables - all tables for expression evaluation
230
+ * @yields {AsyncRow} joined rows
231
+ */
232
+ async function* hashJoin({ leftRows, rightRows, join, leftTable, rightTable, tables }) {
233
+ const { joinType, on: onCondition } = join
234
+
235
+ if (!onCondition) {
236
+ throw new Error('JOIN requires ON condition')
237
+ }
238
+
239
+ const keys = extractJoinKeys(onCondition, leftTable, rightTable)
240
+
241
+ // Get column names for NULL row generation (right side is always buffered)
242
+ const rightCols = rightRows.length ? Object.keys(rightRows[0]) : []
243
+ const rightPrefixedCols = rightCols.flatMap(col =>
244
+ col.includes('.') ? [col] : [`${rightTable}.${col}`, col]
245
+ )
246
+
247
+ // Track left column info - captured from first row during iteration
248
+ /** @type {string[]|null} */
249
+ let leftPrefixedCols = null
250
+
251
+ if (keys) {
252
+ // Hash join: build hash map on right table
253
+ /** @type {Map<string, AsyncRow[]>} */
254
+ const hashMap = new Map()
255
+
256
+ // BUILD PHASE: Index right rows by join key
257
+ // Skip null keys - SQL semantics: NULL != NULL
258
+ for (const rightRow of rightRows) {
259
+ const keyValue = await evaluateExpr({ node: keys.rightKey, row: rightRow, tables })
260
+ if (keyValue == null) continue // NULL keys never match
261
+ const keyStr = JSON.stringify(keyValue)
262
+
263
+ let bucket = hashMap.get(keyStr)
264
+ if (!bucket) {
265
+ bucket = []
266
+ hashMap.set(keyStr, bucket)
267
+ }
268
+ bucket.push(rightRow)
269
+ }
270
+
271
+ // Track which right rows matched (only needed for RIGHT/FULL joins)
272
+ /** @type {Set<AsyncRow>|null} */
273
+ const matchedRightRows = joinType === 'RIGHT' || joinType === 'FULL' ? new Set() : null
274
+
275
+ // PROBE PHASE: Stream through left rows, yield matches immediately
276
+ for await (const leftRow of leftRows) {
277
+ // Capture left column info from first row (for NULL row generation)
278
+ if (!leftPrefixedCols) {
279
+ const leftCols = Object.keys(leftRow)
280
+ leftPrefixedCols = leftCols.flatMap(col =>
281
+ col.includes('.') ? [col] : [`${leftTable}.${col}`, col]
282
+ )
283
+ }
284
+
285
+ const keyValue = await evaluateExpr({ node: keys.leftKey, row: leftRow, tables })
286
+ const keyStr = JSON.stringify(keyValue)
287
+
288
+ const matchingRightRows = hashMap.get(keyStr)
289
+
290
+ if (matchingRightRows && matchingRightRows.length > 0) {
291
+ for (const rightRow of matchingRightRows) {
292
+ if (matchedRightRows) matchedRightRows.add(rightRow)
293
+ yield mergeRows(leftRow, rightRow, leftTable, rightTable)
294
+ }
295
+ } else if (joinType === 'LEFT' || joinType === 'FULL') {
296
+ const nullRight = createNullRow(rightPrefixedCols)
297
+ yield mergeRows(leftRow, nullRight, leftTable, rightTable)
298
+ }
299
+ // INNER join with no match: don't yield anything
300
+ }
301
+
302
+ // UNMATCHED PHASE: Handle unmatched right rows for RIGHT/FULL joins
303
+ if (matchedRightRows) {
304
+ for (const rightRow of rightRows) {
305
+ if (!matchedRightRows.has(rightRow)) {
306
+ // Use empty array if left table was empty (no rows to derive columns from)
307
+ const nullLeft = createNullRow(leftPrefixedCols || [])
308
+ yield mergeRows(nullLeft, rightRow, leftTable, rightTable)
309
+ }
310
+ }
311
+ }
312
+ } else {
313
+ // Fallback to nested loop for complex ON conditions
314
+ // Left rows stream through, right rows are iterated for each left row
315
+ /** @type {Set<AsyncRow>|null} */
316
+ const matchedRightRows = joinType === 'RIGHT' || joinType === 'FULL' ? new Set() : null
317
+
318
+ for await (const leftRow of leftRows) {
319
+ // Capture left column info from first row (for NULL row generation)
320
+ if (!leftPrefixedCols) {
321
+ const leftCols = Object.keys(leftRow)
322
+ leftPrefixedCols = leftCols.flatMap(col =>
323
+ col.includes('.') ? [col] : [`${leftTable}.${col}`, col]
324
+ )
325
+ }
326
+
327
+ let hasMatch = false
328
+
329
+ for (const rightRow of rightRows) {
330
+ const tempMerged = mergeRows(leftRow, rightRow, leftTable, rightTable)
331
+ const matches = await evaluateExpr({ node: onCondition, row: tempMerged, tables })
332
+
333
+ if (matches) {
334
+ hasMatch = true
335
+ if (matchedRightRows) matchedRightRows.add(rightRow)
336
+ yield tempMerged
337
+ }
338
+ }
339
+
340
+ if (!hasMatch && (joinType === 'LEFT' || joinType === 'FULL')) {
341
+ const nullRight = createNullRow(rightPrefixedCols)
342
+ yield mergeRows(leftRow, nullRight, leftTable, rightTable)
343
+ }
344
+ }
345
+
346
+ // Handle unmatched right rows for RIGHT/FULL joins
347
+ if (matchedRightRows) {
348
+ for (const rightRow of rightRows) {
349
+ if (!matchedRightRows.has(rightRow)) {
350
+ // Use empty array if left table was empty (no rows to derive columns from)
351
+ const nullLeft = createNullRow(leftPrefixedCols || [])
352
+ yield mergeRows(nullLeft, rightRow, leftTable, rightTable)
353
+ }
354
+ }
355
+ }
356
+ }
357
+ }
@@ -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, SqlPrimitive} from '../types.js'
5
42
  * @param {AsyncGenerator<AsyncRow>} asyncRows
6
43
  * @returns {Promise<Record<string, SqlPrimitive>[]>} array of all yielded values
7
44
  */
@@ -18,3 +55,35 @@ export async function collect(asyncRows) {
18
55
  }
19
56
  return results
20
57
  }
58
+
59
+ /**
60
+ * Generates a default alias for a derived column expression
61
+ *
62
+ * @param {ExprNode} expr - the expression node
63
+ * @returns {string} the generated alias
64
+ */
65
+ export function defaultDerivedAlias(expr) {
66
+ if (expr.type === 'identifier') {
67
+ // For qualified names like 'users.name', use just the column part as alias
68
+ if (expr.name.includes('.')) {
69
+ return expr.name.split('.').pop()
70
+ }
71
+ return expr.name
72
+ }
73
+ if (expr.type === 'literal') {
74
+ return String(expr.value)
75
+ }
76
+ if (expr.type === 'cast') {
77
+ return defaultDerivedAlias(expr.expr) + '_as_' + expr.toType
78
+ }
79
+ if (expr.type === 'unary') {
80
+ return expr.op + '_' + defaultDerivedAlias(expr.argument)
81
+ }
82
+ if (expr.type === 'binary') {
83
+ return defaultDerivedAlias(expr.left) + '_' + expr.op + '_' + defaultDerivedAlias(expr.right)
84
+ }
85
+ if (expr.type === 'function') {
86
+ return expr.name.toLowerCase() + '_' + expr.args.map(defaultDerivedAlias).join('_')
87
+ }
88
+ return 'expr'
89
+ }
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
@@ -1,7 +1,7 @@
1
1
  import { isAggregateFunc, isStringFunc } from '../validation.js'
2
2
 
3
3
  /**
4
- * @import { ExprCursor, ExprNode, BinaryOp } from '../types.js'
4
+ * @import { BinaryOp, ExprCursor, ExprNode, WhenClause } from '../types.js'
5
5
  */
6
6
 
7
7
  /**
@@ -153,7 +153,7 @@ function parsePrimary(c) {
153
153
  c.consume() // CASE
154
154
 
155
155
  // Check if it's simple CASE (CASE expr WHEN ...) or searched CASE (CASE WHEN ...)
156
- /** @type {import('../types.js').ExprNode | undefined} */
156
+ /** @type {ExprNode | undefined} */
157
157
  let caseExpr
158
158
  const nextTok = c.current()
159
159
  if (nextTok.type !== 'keyword' || nextTok.value !== 'WHEN') {
@@ -162,7 +162,7 @@ function parsePrimary(c) {
162
162
  }
163
163
 
164
164
  // Parse WHEN clauses
165
- /** @type {import('../types.js').WhenClause[]} */
165
+ /** @type {WhenClause[]} */
166
166
  const whenClauses = []
167
167
  while (c.match('keyword', 'WHEN')) {
168
168
  const condition = parseExpression(c)
@@ -176,7 +176,7 @@ function parsePrimary(c) {
176
176
  }
177
177
 
178
178
  // Parse optional ELSE clause
179
- /** @type {import('../types.js').ExprNode | undefined} */
179
+ /** @type {ExprNode | undefined} */
180
180
  let elseResult
181
181
  if (c.match('keyword', 'ELSE')) {
182
182
  elseResult = parseExpression(c)
@@ -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
  }