squirreling 0.7.9 → 0.8.0

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.
@@ -1,197 +1,218 @@
1
+ import { evaluateExpr } from '../expression/evaluate.js'
1
2
  import { missingClauseError } from '../parseErrors.js'
2
- import { evaluateExpr } from './expression.js'
3
- import { resolveTableSource } from './tableSource.js'
4
3
  import { stringify } from './utils.js'
4
+ import { executePlan } from './execute.js'
5
5
 
6
6
  /**
7
- * @import { AsyncRow, AsyncDataSource, JoinClause, ExprNode, AsyncCells, UserDefinedFunction, WithClause } from '../types.js'
7
+ * @import { AsyncCells, AsyncRow } from '../types.js'
8
+ * @import { ExecuteContext, HashJoinNode, NestedLoopJoinNode, PositionalJoinNode } from '../plan/types.js'
8
9
  */
9
10
 
10
11
  /**
11
- * Executes JOIN operations against a base data source
12
+ * Executes a nested loop join operation
12
13
  *
13
- * @param {Object} options
14
- * @param {AsyncDataSource} options.leftSource - the left side of the join (FROM table)
15
- * @param {JoinClause[]} options.joins - array of join clauses to execute
16
- * @param {string} options.leftTable - name of the left table (for column prefixing)
17
- * @param {Record<string, AsyncDataSource>} options.tables - all available tables
18
- * @param {WithClause} [options.withClause] - WITH clause containing CTE definitions
19
- * @param {Record<string, UserDefinedFunction>} [options.functions]
20
- * @param {Function} [options.executeSelectFn] - function to execute SELECT for CTEs (passed to avoid circular dep)
21
- * @param {AbortSignal} [options.signal]
22
- * @returns {Promise<AsyncDataSource>} data source yielding joined rows
14
+ * @param {NestedLoopJoinNode} plan
15
+ * @param {ExecuteContext} context
16
+ * @yields {AsyncRow}
23
17
  */
24
- export async function executeJoins({ leftSource, joins, leftTable, tables, withClause, functions, executeSelectFn, signal }) {
25
- let currentLeftTable = leftTable
26
-
27
- // Single join optimization: stream left rows without buffering
28
- if (joins.length === 1) {
29
- const join = joins[0]
30
- const rightSource = resolveTableSource(join.table, tables, withClause, executeSelectFn, functions, signal)
31
-
32
- // Buffer right rows for hash index (required for hash join)
33
- /** @type {AsyncRow[]} */
34
- const rightRows = []
35
- for await (const row of rightSource.scan({})) {
36
- rightRows.push(row)
37
- }
18
+ export async function* executeNestedLoopJoin(plan, context) {
19
+ const { tables, functions, signal } = context
20
+ const leftTable = plan.leftAlias
21
+ const rightTable = plan.rightAlias
38
22
 
39
- // Use alias for column prefixing if present
40
- const rightTable = join.alias ?? join.table
41
-
42
- // Return streaming data source - left rows stream through without buffering
43
- return {
44
- async *scan(options) {
45
- const { signal } = options
46
- if (join.joinType === 'POSITIONAL') {
47
- yield* positionalJoin({
48
- leftRows: leftSource.scan(options),
49
- rightRows,
50
- leftTable: currentLeftTable,
51
- rightTable,
52
- signal,
53
- })
54
- } else {
55
- yield* hashJoin({
56
- leftRows: leftSource.scan(options), // Stream directly, not buffered
57
- rightRows,
58
- join,
59
- leftTable: currentLeftTable,
60
- rightTable,
61
- tables,
62
- functions,
63
- signal,
64
- })
65
- }
66
- },
67
- }
23
+ if (!plan.condition) {
24
+ throw missingClauseError({
25
+ missing: 'ON condition',
26
+ context: 'JOIN',
27
+ })
68
28
  }
69
29
 
70
- // Multiple joins: buffer intermediate results, stream final join
30
+ // Buffer right rows
71
31
  /** @type {AsyncRow[]} */
72
- let leftRows = []
73
- for await (const row of leftSource.scan({})) {
74
- leftRows.push(row)
32
+ const rightRows = []
33
+ for await (const row of executePlan(plan.right, context)) {
34
+ if (signal?.aborted) return
35
+ rightRows.push(row)
75
36
  }
76
37
 
77
- // Process all but the last join, buffering intermediate results
78
- for (let i = 0; i < joins.length - 1; i++) {
79
- const join = joins[i]
80
- const rightSource = resolveTableSource(join.table, tables, withClause, executeSelectFn, functions, signal)
38
+ const rightCols = rightRows.length ? rightRows[0].columns : []
39
+ const rightPrefixedCols = prefixColumns(rightCols, rightTable)
40
+
41
+ /** @type {string[] | null} */
42
+ let leftPrefixedCols = null
43
+ /** @type {Set<AsyncRow> | null} */
44
+ const matchedRightRows = plan.joinType === 'RIGHT' || plan.joinType === 'FULL' ? new Set() : null
45
+
46
+ for await (const leftRow of executePlan(plan.left, context)) {
47
+ if (signal?.aborted) break
81
48
 
82
- /** @type {AsyncRow[]} */
83
- const rightRows = []
84
- for await (const row of rightSource.scan({})) {
85
- rightRows.push(row)
49
+ if (!leftPrefixedCols) {
50
+ leftPrefixedCols = prefixColumns(leftRow.columns, leftTable)
86
51
  }
87
52
 
88
- // Use alias for column prefixing if present
89
- const rightTable = join.alias ?? join.table
90
-
91
- // Collect intermediate results into array for next join
92
- /** @type {AsyncRow[]} */
93
- const newLeftRows = []
94
- const joined = join.joinType === 'POSITIONAL'
95
- ? positionalJoin({
96
- leftRows,
97
- rightRows,
98
- leftTable: currentLeftTable,
99
- rightTable,
100
- })
101
- : hashJoin({
102
- leftRows,
103
- rightRows,
104
- join,
105
- leftTable: currentLeftTable,
106
- rightTable,
53
+ let hasMatch = false
54
+
55
+ for (const rightRow of rightRows) {
56
+ const tempMerged = mergeRows(leftRow, rightRow, leftTable, rightTable)
57
+ const matches = await evaluateExpr({
58
+ node: plan.condition,
59
+ row: tempMerged,
107
60
  tables,
108
61
  functions,
62
+ signal,
109
63
  })
110
- for await (const row of joined) {
111
- newLeftRows.push(row)
64
+
65
+ if (matches) {
66
+ hasMatch = true
67
+ if (matchedRightRows) matchedRightRows.add(rightRow)
68
+ yield tempMerged
69
+ }
70
+ }
71
+
72
+ if (!hasMatch && (plan.joinType === 'LEFT' || plan.joinType === 'FULL')) {
73
+ const nullRight = createNullRow(rightPrefixedCols)
74
+ yield mergeRows(leftRow, nullRight, leftTable, rightTable)
112
75
  }
113
- leftRows = newLeftRows
76
+ }
114
77
 
115
- // After join, the "left" table for the next join includes all joined tables
116
- currentLeftTable = `${currentLeftTable}_${rightTable}`
78
+ // Unmatched right rows for RIGHT/FULL joins
79
+ if (matchedRightRows) {
80
+ for (const rightRow of rightRows) {
81
+ if (!matchedRightRows.has(rightRow)) {
82
+ const nullLeft = createNullRow(leftPrefixedCols || [])
83
+ yield mergeRows(nullLeft, rightRow, leftTable, rightTable)
84
+ }
85
+ }
117
86
  }
87
+ }
118
88
 
119
- // Final join: stream the results
120
- const join = joins[joins.length - 1]
121
- const rightSource = resolveTableSource(join.table, tables, withClause, executeSelectFn, functions, signal)
89
+ /**
90
+ * Executes a positional join operation
91
+ *
92
+ * @param {PositionalJoinNode} plan
93
+ * @param {ExecuteContext} context
94
+ * @yields {AsyncRow}
95
+ */
96
+ export async function* executePositionalJoin(plan, context) {
97
+ const { signal } = context
98
+ const leftTable = plan.leftAlias
99
+ const rightTable = plan.rightAlias
100
+
101
+ // Buffer both sides (required for positional join)
102
+ /** @type {AsyncRow[]} */
103
+ const leftRows = []
104
+ for await (const row of executePlan(plan.left, context)) {
105
+ if (signal?.aborted) return
106
+ leftRows.push(row)
107
+ }
122
108
 
123
109
  /** @type {AsyncRow[]} */
124
110
  const rightRows = []
125
- for await (const row of rightSource.scan({})) {
111
+ for await (const row of executePlan(plan.right, context)) {
112
+ if (signal?.aborted) return
126
113
  rightRows.push(row)
127
114
  }
128
115
 
129
- // Use alias for column prefixing if present
130
- const rightTable = join.alias ?? join.table
131
-
132
- return {
133
- async *scan(options) {
134
- const { signal } = options
135
- if (join.joinType === 'POSITIONAL') {
136
- yield* positionalJoin({
137
- leftRows,
138
- rightRows,
139
- leftTable: currentLeftTable,
140
- rightTable,
141
- signal,
142
- })
143
- } else {
144
- yield* hashJoin({
145
- leftRows,
146
- rightRows,
147
- join,
148
- leftTable: currentLeftTable,
149
- rightTable,
150
- tables,
151
- functions,
152
- signal,
153
- })
154
- }
155
- },
116
+ const maxLen = Math.max(leftRows.length, rightRows.length)
117
+ const leftCols = leftRows[0]?.columns ?? []
118
+ const rightCols = rightRows[0]?.columns ?? []
119
+ const leftPrefixedCols = prefixColumns(leftCols, leftTable)
120
+ const rightPrefixedCols = prefixColumns(rightCols, rightTable)
121
+
122
+ for (let i = 0; i < maxLen; i++) {
123
+ if (signal?.aborted) return
124
+ const leftRow = leftRows[i] ?? createNullRow(leftPrefixedCols)
125
+ const rightRow = rightRows[i] ?? createNullRow(rightPrefixedCols)
126
+ yield mergeRows(leftRow, rightRow, leftTable, rightTable)
156
127
  }
157
128
  }
158
129
 
159
130
  /**
160
- * Checks if an expression references a specific table.
161
- * Returns true if the expression is an identifier prefixed with the table name.
131
+ * Executes a hash join operation
162
132
  *
163
- * @param {ExprNode} expr
164
- * @param {string} tableName
165
- * @returns {boolean}
133
+ * @param {HashJoinNode} plan
134
+ * @param {ExecuteContext} context
135
+ * @yields {AsyncRow}
166
136
  */
167
- function exprReferencesTable(expr, tableName) {
168
- return expr.type === 'identifier' && expr.name.startsWith(`${tableName}.`)
169
- }
137
+ export async function* executeHashJoin(plan, context) {
138
+ const { tables, functions, signal } = context
139
+ const leftTable = plan.leftAlias
140
+ const rightTable = plan.rightAlias
170
141
 
171
- /**
172
- * Extracts the join key expressions from an ON condition.
173
- * Handles both `left.col = right.col` and `right.col = left.col` orderings.
174
- *
175
- * @param {ExprNode} onCondition
176
- * @param {string} leftTable
177
- * @param {string} rightTable
178
- * @returns {{ leftKey: ExprNode, rightKey: ExprNode } | undefined}
179
- */
180
- function extractJoinKeys(onCondition, leftTable, rightTable) {
181
- if (onCondition.type === 'binary' && onCondition.op === '=') {
182
- const { left, right } = onCondition
142
+ // Buffer right rows and build hash map
143
+ /** @type {AsyncRow[]} */
144
+ const rightRows = []
145
+ for await (const row of executePlan(plan.right, context)) {
146
+ if (signal?.aborted) return
147
+ rightRows.push(row)
148
+ }
183
149
 
184
- // Check if keys are swapped (right table referenced in left position)
185
- const leftRefsRight = exprReferencesTable(left, rightTable)
186
- const rightRefsLeft = exprReferencesTable(right, leftTable)
150
+ /** @type {Map<string, AsyncRow[]>} */
151
+ const hashMap = new Map()
152
+ for (const rightRow of rightRows) {
153
+ const keyValue = await evaluateExpr({
154
+ node: plan.rightKey,
155
+ row: rightRow,
156
+ tables,
157
+ functions,
158
+ signal,
159
+ })
160
+ if (keyValue == null) continue
161
+ const keyStr = stringify(keyValue)
162
+ let bucket = hashMap.get(keyStr)
163
+ if (!bucket) {
164
+ bucket = []
165
+ hashMap.set(keyStr, bucket)
166
+ }
167
+ bucket.push(rightRow)
168
+ }
187
169
 
188
- if (leftRefsRight && rightRefsLeft) {
189
- // Keys are swapped, return them in correct order
190
- return { leftKey: right, rightKey: left }
170
+ // Get column info for NULL row generation
171
+ const rightCols = rightRows.length ? rightRows[0].columns : []
172
+ const rightPrefixedCols = prefixColumns(rightCols, rightTable)
173
+
174
+ /** @type {string[] | null} */
175
+ let leftPrefixedCols = null
176
+ /** @type {Set<AsyncRow> | null} */
177
+ const matchedRightRows = plan.joinType === 'RIGHT' || plan.joinType === 'FULL' ? new Set() : null
178
+
179
+ // Probe phase: stream left rows
180
+ for await (const leftRow of executePlan(plan.left, context)) {
181
+ if (signal?.aborted) break
182
+
183
+ if (!leftPrefixedCols) {
184
+ leftPrefixedCols = prefixColumns(leftRow.columns, leftTable)
191
185
  }
192
186
 
193
- // Default: assume left operand is for left table
194
- return { leftKey: left, rightKey: right }
187
+ const keyValue = await evaluateExpr({
188
+ node: plan.leftKey,
189
+ row: leftRow,
190
+ tables,
191
+ functions,
192
+ signal,
193
+ })
194
+ const keyStr = stringify(keyValue)
195
+ const matchingRightRows = hashMap.get(keyStr)
196
+
197
+ if (matchingRightRows?.length) {
198
+ for (const rightRow of matchingRightRows) {
199
+ if (matchedRightRows) matchedRightRows.add(rightRow)
200
+ yield mergeRows(leftRow, rightRow, leftTable, rightTable)
201
+ }
202
+ } else if (plan.joinType === 'LEFT' || plan.joinType === 'FULL') {
203
+ const nullRight = createNullRow(rightPrefixedCols)
204
+ yield mergeRows(leftRow, nullRight, leftTable, rightTable)
205
+ }
206
+ }
207
+
208
+ // Unmatched right rows for RIGHT/FULL joins
209
+ if (matchedRightRows) {
210
+ for (const rightRow of rightRows) {
211
+ if (!matchedRightRows.has(rightRow)) {
212
+ const nullLeft = createNullRow(leftPrefixedCols || [])
213
+ yield mergeRows(nullLeft, rightRow, leftTable, rightTable)
214
+ }
215
+ }
195
216
  }
196
217
  }
197
218
 
@@ -252,187 +273,12 @@ function mergeRows(leftRow, rightRow, leftTable, rightTable) {
252
273
  }
253
274
 
254
275
  /**
255
- * Performs a positional join between left and right row sets.
256
- * Matches rows by their index position (row 0 with row 0, row 1 with row 1, etc.).
257
- * When tables have different lengths, the shorter table is padded with NULLs.
276
+ * Prefixes column names with table alias, keeping already-prefixed columns as-is
258
277
  *
259
- * @param {Object} params
260
- * @param {AsyncIterable<AsyncRow>|AsyncRow[]} params.leftRows - rows from left table
261
- * @param {AsyncRow[]} params.rightRows - rows from right table (must be buffered)
262
- * @param {string} params.leftTable - name of left table (for column prefixing)
263
- * @param {string} params.rightTable - name of right table (for column prefixing, may be alias)
264
- * @param {AbortSignal} [params.signal] - abort signal for cancellation
265
- * @yields {AsyncRow} joined rows
278
+ * @param {string[]} cols
279
+ * @param {string} table
280
+ * @returns {string[]}
266
281
  */
267
- async function* positionalJoin({ leftRows, rightRows, leftTable, rightTable, signal }) {
268
- // Buffer left rows if streaming
269
- /** @type {AsyncRow[]} */
270
- const leftArr = []
271
- for await (const row of leftRows) {
272
- if (signal?.aborted) return
273
- leftArr.push(row)
274
- }
275
-
276
- const maxLen = Math.max(leftArr.length, rightRows.length)
277
-
278
- // Get column info for NULL row creation
279
- const leftCols = leftArr[0]?.columns ?? []
280
- const rightCols = rightRows[0]?.columns ?? []
281
- const leftPrefixedCols = leftCols.flatMap(col =>
282
- col.includes('.') ? [col] : [`${leftTable}.${col}`, col]
283
- )
284
- const rightPrefixedCols = rightCols.flatMap(col =>
285
- col.includes('.') ? [col] : [`${rightTable}.${col}`, col]
286
- )
287
-
288
- for (let i = 0; i < maxLen; i++) {
289
- if (signal?.aborted) return
290
- const leftRow = leftArr[i] ?? createNullRow(leftPrefixedCols)
291
- const rightRow = rightRows[i] ?? createNullRow(rightPrefixedCols)
292
- yield mergeRows(leftRow, rightRow, leftTable, rightTable)
293
- }
294
- }
295
-
296
- /**
297
- * Performs a hash join between left and right row sets (streaming).
298
- * Yields rows as they are found instead of buffering all results.
299
- *
300
- * @param {Object} params
301
- * @param {AsyncIterable<AsyncRow>|AsyncRow[]} params.leftRows - rows from left table (can stream)
302
- * @param {AsyncRow[]} params.rightRows - rows from right table (must be buffered for hash index)
303
- * @param {JoinClause} params.join - join specification
304
- * @param {string} params.leftTable - name of left table (for column prefixing)
305
- * @param {string} params.rightTable - name of right table (for column prefixing, may be alias)
306
- * @param {Record<string, AsyncDataSource>} params.tables - all tables for expression evaluation
307
- * @param {Record<string, UserDefinedFunction>} [params.functions]
308
- * @param {AbortSignal} [params.signal] - abort signal for cancellation
309
- * @yields {AsyncRow} joined rows
310
- */
311
- async function* hashJoin({ leftRows, rightRows, join, leftTable, rightTable, tables, functions, signal }) {
312
- const { joinType, on: onCondition } = join
313
-
314
- if (!onCondition) {
315
- throw missingClauseError({
316
- missing: 'ON condition',
317
- context: 'JOIN',
318
- })
319
- }
320
-
321
- const keys = extractJoinKeys(onCondition, leftTable, rightTable)
322
-
323
- // Get column names for NULL row generation (right side is always buffered)
324
- const rightCols = rightRows.length ? rightRows[0].columns : []
325
- const rightPrefixedCols = rightCols.flatMap(col =>
326
- col.includes('.') ? [col] : [`${rightTable}.${col}`, col]
327
- )
328
-
329
- // Track left column info - captured from first row during iteration
330
- /** @type {string[]|null} */
331
- let leftPrefixedCols = null
332
-
333
- if (keys) {
334
- // Hash join: build hash map on right table
335
- /** @type {Map<string, AsyncRow[]>} */
336
- const hashMap = new Map()
337
-
338
- // BUILD PHASE: Index right rows by join key
339
- // Skip null keys - SQL semantics: NULL != NULL
340
- for (const rightRow of rightRows) {
341
- const keyValue = await evaluateExpr({ node: keys.rightKey, row: rightRow, tables, functions })
342
- if (keyValue == null) continue // NULL keys never match
343
- const keyStr = stringify(keyValue)
344
- let bucket = hashMap.get(keyStr)
345
- if (!bucket) {
346
- bucket = []
347
- hashMap.set(keyStr, bucket)
348
- }
349
- bucket.push(rightRow)
350
- }
351
-
352
- // Track which right rows matched (only needed for RIGHT/FULL joins)
353
- /** @type {Set<AsyncRow>|null} */
354
- const matchedRightRows = joinType === 'RIGHT' || joinType === 'FULL' ? new Set() : null
355
-
356
- // PROBE PHASE: Stream through left rows, yield matches immediately
357
- for await (const leftRow of leftRows) {
358
- if (signal?.aborted) break
359
- // Capture left column info from first row (for NULL row generation)
360
- if (!leftPrefixedCols) {
361
- leftPrefixedCols = leftRow.columns.flatMap(col =>
362
- col.includes('.') ? [col] : [`${leftTable}.${col}`, col]
363
- )
364
- }
365
-
366
- const keyValue = await evaluateExpr({ node: keys.leftKey, row: leftRow, tables, functions })
367
- const keyStr = stringify(keyValue)
368
-
369
- const matchingRightRows = hashMap.get(keyStr)
370
-
371
- if (matchingRightRows && matchingRightRows.length > 0) {
372
- for (const rightRow of matchingRightRows) {
373
- if (matchedRightRows) matchedRightRows.add(rightRow)
374
- yield mergeRows(leftRow, rightRow, leftTable, rightTable)
375
- }
376
- } else if (joinType === 'LEFT' || joinType === 'FULL') {
377
- const nullRight = createNullRow(rightPrefixedCols)
378
- yield mergeRows(leftRow, nullRight, leftTable, rightTable)
379
- }
380
- // INNER join with no match: don't yield anything
381
- }
382
-
383
- // UNMATCHED PHASE: Handle unmatched right rows for RIGHT/FULL joins
384
- if (matchedRightRows) {
385
- for (const rightRow of rightRows) {
386
- if (!matchedRightRows.has(rightRow)) {
387
- // Use empty array if left table was empty (no rows to derive columns from)
388
- const nullLeft = createNullRow(leftPrefixedCols || [])
389
- yield mergeRows(nullLeft, rightRow, leftTable, rightTable)
390
- }
391
- }
392
- }
393
- } else {
394
- // Fallback to nested loop for complex ON conditions
395
- // Left rows stream through, right rows are iterated for each left row
396
- /** @type {Set<AsyncRow>|null} */
397
- const matchedRightRows = joinType === 'RIGHT' || joinType === 'FULL' ? new Set() : null
398
-
399
- for await (const leftRow of leftRows) {
400
- if (signal?.aborted) break
401
- // Capture left column info from first row (for NULL row generation)
402
- if (!leftPrefixedCols) {
403
- leftPrefixedCols = leftRow.columns.flatMap(col =>
404
- col.includes('.') ? [col] : [`${leftTable}.${col}`, col]
405
- )
406
- }
407
-
408
- let hasMatch = false
409
-
410
- for (const rightRow of rightRows) {
411
- const tempMerged = mergeRows(leftRow, rightRow, leftTable, rightTable)
412
- const matches = await evaluateExpr({ node: onCondition, row: tempMerged, tables, functions })
413
-
414
- if (matches) {
415
- hasMatch = true
416
- if (matchedRightRows) matchedRightRows.add(rightRow)
417
- yield tempMerged
418
- }
419
- }
420
-
421
- if (!hasMatch && (joinType === 'LEFT' || joinType === 'FULL')) {
422
- const nullRight = createNullRow(rightPrefixedCols)
423
- yield mergeRows(leftRow, nullRight, leftTable, rightTable)
424
- }
425
- }
426
-
427
- // Handle unmatched right rows for RIGHT/FULL joins
428
- if (matchedRightRows) {
429
- for (const rightRow of rightRows) {
430
- if (!matchedRightRows.has(rightRow)) {
431
- // Use empty array if left table was empty (no rows to derive columns from)
432
- const nullLeft = createNullRow(leftPrefixedCols || [])
433
- yield mergeRows(nullLeft, rightRow, leftTable, rightTable)
434
- }
435
- }
436
- }
437
- }
282
+ function prefixColumns(cols, table) {
283
+ return cols.flatMap(col => col.includes('.') ? [col] : [`${table}.${col}`, col])
438
284
  }
@@ -0,0 +1,99 @@
1
+ import { evaluateExpr } from '../expression/evaluate.js'
2
+ import { executePlan } from './execute.js'
3
+ import { compareForTerm } from './utils.js'
4
+
5
+ /**
6
+ * @import { AsyncRow, SqlPrimitive } from '../types.js'
7
+ * @import { ExecuteContext, SortNode } from '../plan/types.js'
8
+ */
9
+
10
+ /**
11
+ * Executes a sort operation (ORDER BY)
12
+ *
13
+ * @param {SortNode} plan
14
+ * @param {ExecuteContext} context
15
+ * @yields {AsyncRow}
16
+ */
17
+ export async function* executeSort(plan, context) {
18
+ const { tables, functions, signal } = context
19
+
20
+ // Buffer all rows
21
+ /** @type {AsyncRow[]} */
22
+ const rows = []
23
+ for await (const row of executePlan(plan.child, context)) {
24
+ if (signal?.aborted) return
25
+ rows.push(row)
26
+ }
27
+
28
+ if (rows.length === 0) return
29
+
30
+ // Multi-pass lazy sorting
31
+ /** @type {(SqlPrimitive | undefined)[][]} */
32
+ const evaluatedValues = rows.map(() => Array(plan.orderBy.length))
33
+
34
+ /** @type {number[][]} */
35
+ let groups = [rows.map((_, i) => i)]
36
+
37
+ for (let orderByIdx = 0; orderByIdx < plan.orderBy.length; orderByIdx++) {
38
+ const term = plan.orderBy[orderByIdx]
39
+ /** @type {number[][]} */
40
+ const nextGroups = []
41
+
42
+ for (const group of groups) {
43
+ if (group.length <= 1) {
44
+ nextGroups.push(group)
45
+ continue
46
+ }
47
+
48
+ // Evaluate this column for all rows in the group
49
+ for (const idx of group) {
50
+ if (evaluatedValues[idx][orderByIdx] === undefined) {
51
+ evaluatedValues[idx][orderByIdx] = await evaluateExpr({
52
+ node: term.expr,
53
+ row: rows[idx],
54
+ tables,
55
+ functions,
56
+ aliases: plan.aliases,
57
+ signal,
58
+ })
59
+ }
60
+ }
61
+
62
+ // Sort the group by this column
63
+ group.sort((aIdx, bIdx) => {
64
+ const av = evaluatedValues[aIdx][orderByIdx]
65
+ const bv = evaluatedValues[bIdx][orderByIdx]
66
+ return compareForTerm(av, bv, term)
67
+ })
68
+
69
+ // Split into sub-groups based on ties
70
+ if (orderByIdx < plan.orderBy.length - 1) {
71
+ /** @type {number[]} */
72
+ let currentSubGroup = [group[0]]
73
+ for (let i = 1; i < group.length; i++) {
74
+ const prevIdx = group[i - 1]
75
+ const currIdx = group[i]
76
+ const prevVal = evaluatedValues[prevIdx][orderByIdx]
77
+ const currVal = evaluatedValues[currIdx][orderByIdx]
78
+
79
+ if (compareForTerm(prevVal, currVal, term) === 0) {
80
+ currentSubGroup.push(currIdx)
81
+ } else {
82
+ nextGroups.push(currentSubGroup)
83
+ currentSubGroup = [currIdx]
84
+ }
85
+ }
86
+ nextGroups.push(currentSubGroup)
87
+ } else {
88
+ nextGroups.push(group)
89
+ }
90
+ }
91
+
92
+ groups = nextGroups
93
+ }
94
+
95
+ // Yield sorted rows
96
+ for (const idx of groups.flat()) {
97
+ yield rows[idx]
98
+ }
99
+ }