squirreling 0.12.8 → 0.12.9

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.12.8",
3
+ "version": "0.12.9",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
package/src/ast.d.ts CHANGED
@@ -106,6 +106,14 @@ export interface FunctionNode extends AstBase {
106
106
  filter?: ExprNode
107
107
  }
108
108
 
109
+ export interface WindowFunctionNode extends AstBase {
110
+ type: 'window'
111
+ funcName: string
112
+ args: ExprNode[]
113
+ partitionBy: ExprNode[]
114
+ orderBy: OrderByItem[]
115
+ }
116
+
109
117
  export type CastType = 'TEXT' | 'STRING' | 'VARCHAR' | 'INTEGER' | 'INT' | 'BIGINT' | 'FLOAT' | 'REAL' | 'DOUBLE' | 'BOOLEAN' | 'BOOL'
110
118
 
111
119
  export interface CastNode extends AstBase {
@@ -166,6 +174,7 @@ export type ExprNode =
166
174
  | UnaryNode
167
175
  | BinaryNode
168
176
  | FunctionNode
177
+ | WindowFunctionNode
169
178
  | CastNode
170
179
  | InSubqueryNode
171
180
  | InValuesNode
@@ -9,6 +9,7 @@ import { executeHashAggregate, executeScalarAggregate } from './aggregates.js'
9
9
  import { executeHashJoin, executeNestedLoopJoin, executePositionalJoin } from './join.js'
10
10
  import { executeSort } from './sort.js'
11
11
  import { addBounds, minBounds, stableRowKey } from './utils.js'
12
+ import { executeWindow } from './window.js'
12
13
 
13
14
  /**
14
15
  * @import { AsyncCells, AsyncDataSource, AsyncRow, DerivedColumn, ExecuteContext, ExecuteSqlOptions, ExprNode, IdentifierNode, QueryResults, SelectColumn, SqlPrimitive, Statement } from '../types.js'
@@ -120,6 +121,8 @@ export function executePlan({ plan, context }) {
120
121
  return executeSetOperation(plan, context)
121
122
  } else if (plan.type === 'TableFunction') {
122
123
  return executeTableFunction(plan, context)
124
+ } else if (plan.type === 'Window') {
125
+ return executeWindow(plan, context)
123
126
  }
124
127
  return { columns: [], async *rows() {} }
125
128
  }
@@ -0,0 +1,154 @@
1
+ import { evaluateExpr } from '../expression/evaluate.js'
2
+ import { executePlan } from './execute.js'
3
+ import { compareForTerm, keyify } from './utils.js'
4
+
5
+ /**
6
+ * @import { AsyncRow, ExecuteContext, QueryResults, SqlPrimitive } from '../types.js'
7
+ * @import { WindowNode, WindowSpec } from '../plan/types.js'
8
+ */
9
+
10
+ /**
11
+ * Executes a Window plan node: buffers the child's rows, assigns each window
12
+ * function's output per partition, and yields rows in input order with the
13
+ * synthetic window cells attached.
14
+ *
15
+ * @param {WindowNode} plan
16
+ * @param {ExecuteContext} context
17
+ * @returns {QueryResults}
18
+ */
19
+ export function executeWindow(plan, context) {
20
+ const child = executePlan({ plan: plan.child, context })
21
+ const extraColumns = plan.windows.map(w => w.alias)
22
+
23
+ // Streaming fast path: every window is OVER () with no partition/order, so
24
+ // each row's output depends only on its position in the input stream. Avoids
25
+ // buffering — critical for large scans (e.g. parquet).
26
+ const streamable = plan.windows.every(w => w.partitionBy.length === 0 && w.orderBy.length === 0)
27
+
28
+ if (streamable) {
29
+ return {
30
+ columns: [...child.columns, ...extraColumns],
31
+ numRows: child.numRows,
32
+ maxRows: child.maxRows,
33
+ async *rows() {
34
+ let i = 0
35
+ for await (const row of child.rows()) {
36
+ if (context.signal?.aborted) return
37
+ i++
38
+ const cells = { ...row.cells }
39
+ for (const w of plan.windows) {
40
+ const value = assignRowNumber(w.funcName, i - 1)
41
+ cells[w.alias] = () => Promise.resolve(value)
42
+ }
43
+ yield {
44
+ columns: [...row.columns, ...extraColumns],
45
+ cells,
46
+ }
47
+ }
48
+ },
49
+ }
50
+ }
51
+
52
+ return {
53
+ columns: [...child.columns, ...extraColumns],
54
+ numRows: child.numRows,
55
+ maxRows: child.maxRows,
56
+ async *rows() {
57
+ /** @type {AsyncRow[]} */
58
+ const rows = []
59
+ for await (const row of child.rows()) {
60
+ if (context.signal?.aborted) return
61
+ rows.push(row)
62
+ }
63
+ if (rows.length === 0) return
64
+
65
+ // One SqlPrimitive per window spec per row, indexed by row input position.
66
+ /** @type {SqlPrimitive[][]} */
67
+ const windowValues = plan.windows.map(() => new Array(rows.length))
68
+
69
+ for (let w = 0; w < plan.windows.length; w++) {
70
+ await computeWindow(plan.windows[w], rows, windowValues[w], context)
71
+ if (context.signal?.aborted) return
72
+ }
73
+
74
+ for (let i = 0; i < rows.length; i++) {
75
+ if (context.signal?.aborted) return
76
+ const row = rows[i]
77
+ const cells = { ...row.cells }
78
+ for (let w = 0; w < plan.windows.length; w++) {
79
+ const { alias } = plan.windows[w]
80
+ const value = windowValues[w][i]
81
+ cells[alias] = () => Promise.resolve(value)
82
+ }
83
+ yield {
84
+ columns: [...row.columns, ...extraColumns],
85
+ cells,
86
+ }
87
+ }
88
+ },
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Computes a single window function across all rows, writing the per-row
94
+ * output values into `output`.
95
+ *
96
+ * @param {WindowSpec} spec
97
+ * @param {AsyncRow[]} rows
98
+ * @param {SqlPrimitive[]} output
99
+ * @param {ExecuteContext} context
100
+ */
101
+ async function computeWindow(spec, rows, output, context) {
102
+ // Bucket row indices by partition key.
103
+ /** @type {Map<string | number | bigint | boolean, number[]>} */
104
+ const partitions = new Map()
105
+ const partitionKeys = await Promise.all(rows.map(row =>
106
+ Promise.all(spec.partitionBy.map(expr => evaluateExpr({ node: expr, row, context })))
107
+ ))
108
+ for (let i = 0; i < rows.length; i++) {
109
+ const key = keyify(...partitionKeys[i])
110
+ let bucket = partitions.get(key)
111
+ if (!bucket) {
112
+ bucket = []
113
+ partitions.set(key, bucket)
114
+ }
115
+ bucket.push(i)
116
+ }
117
+
118
+ for (const bucket of partitions.values()) {
119
+ if (context.signal?.aborted) return
120
+
121
+ // Order within the partition. Empty ORDER BY → input order.
122
+ if (spec.orderBy.length) {
123
+ const orderValues = await Promise.all(bucket.map(idx =>
124
+ Promise.all(spec.orderBy.map(term => evaluateExpr({ node: term.expr, row: rows[idx], context })))
125
+ ))
126
+ /** @type {{ idx: number, values: SqlPrimitive[], pos: number }[]} */
127
+ const entries = bucket.map((idx, k) => ({ idx, values: orderValues[k], pos: k }))
128
+ entries.sort((a, b) => {
129
+ for (let i = 0; i < spec.orderBy.length; i++) {
130
+ const cmp = compareForTerm(a.values[i], b.values[i], spec.orderBy[i])
131
+ if (cmp !== 0) return cmp
132
+ }
133
+ return a.pos - b.pos
134
+ })
135
+ for (let k = 0; k < entries.length; k++) {
136
+ output[entries[k].idx] = assignRowNumber(spec.funcName, k)
137
+ }
138
+ } else {
139
+ for (let k = 0; k < bucket.length; k++) {
140
+ output[bucket[k]] = assignRowNumber(spec.funcName, k)
141
+ }
142
+ }
143
+ }
144
+ }
145
+
146
+ /**
147
+ * @param {string} funcName
148
+ * @param {number} rank - 0-based rank within the partition
149
+ * @returns {SqlPrimitive}
150
+ */
151
+ function assignRowNumber(funcName, rank) {
152
+ if (funcName === 'ROW_NUMBER') return rank + 1
153
+ throw new Error(`Unsupported window function: ${funcName}`)
154
+ }
@@ -31,6 +31,9 @@ export function derivedAlias(expr) {
31
31
  }
32
32
  return expr.funcName.toLowerCase() + '_' + expr.args.map(derivedAlias).join('_')
33
33
  }
34
+ if (expr.type === 'window') {
35
+ return expr.funcName.toLowerCase()
36
+ }
34
37
  if (expr.type === 'interval') {
35
38
  return `interval_${expr.value}_${expr.unit.toLowerCase()}`
36
39
  }
@@ -1,10 +1,10 @@
1
- import { isAggregateFunc, isKnownFunction, niladicFuncs, validateFunctionArgs } from '../validation/functions.js'
1
+ import { isAggregateFunc, isKnownFunction, isWindowFunc, niladicFuncs, validateFunctionArgs } from '../validation/functions.js'
2
2
  import { ParseError, UnknownFunctionError } from '../validation/parseErrors.js'
3
3
  import { parseExpression } from './expression.js'
4
4
  import { consume, current, expect, match } from './state.js'
5
5
 
6
6
  /**
7
- * @import { ExprNode, ParserState } from '../types.js'
7
+ * @import { ExprNode, OrderByItem, ParserState } from '../types.js'
8
8
  */
9
9
 
10
10
  /**
@@ -128,13 +128,43 @@ export function parseFunctionCall(state, positionStart) {
128
128
  expect(state, 'paren', ')')
129
129
  }
130
130
 
131
- // Check for OVER clause (window functions not supported)
131
+ // Check for OVER clause
132
132
  const overTok = current(state)
133
- if (overTok.type === 'identifier' && overTok.value.toUpperCase() === 'OVER') {
133
+ const hasOver = overTok.type === 'identifier' && overTok.value.toUpperCase() === 'OVER'
134
+
135
+ if (hasOver) {
136
+ if (!isWindowFunc(funcNameUpper)) {
137
+ throw new ParseError({
138
+ message: `Window functions are not supported: ${funcName}(...) OVER (...)`,
139
+ positionStart,
140
+ positionEnd: overTok.positionEnd,
141
+ })
142
+ }
143
+ if (filter) {
144
+ throw new ParseError({
145
+ message: `FILTER cannot be combined with OVER for "${funcName}"`,
146
+ positionStart,
147
+ positionEnd: overTok.positionEnd,
148
+ })
149
+ }
150
+ consume(state)
151
+ const { partitionBy, orderBy } = parseWindowSpec(state, positionStart)
152
+ return {
153
+ type: 'window',
154
+ funcName,
155
+ args,
156
+ partitionBy,
157
+ orderBy,
158
+ positionStart,
159
+ positionEnd: state.lastPos,
160
+ }
161
+ }
162
+
163
+ if (isWindowFunc(funcNameUpper)) {
134
164
  throw new ParseError({
135
- message: `Window functions are not supported: ${funcName}(...) OVER (...)`,
165
+ message: `${funcName}() requires an OVER clause at position ${positionStart}`,
136
166
  positionStart,
137
- positionEnd: overTok.positionEnd,
167
+ positionEnd: state.lastPos,
138
168
  })
139
169
  }
140
170
 
@@ -148,3 +178,64 @@ export function parseFunctionCall(state, positionStart) {
148
178
  positionEnd: state.lastPos,
149
179
  }
150
180
  }
181
+
182
+ /**
183
+ * Parses the window spec after OVER: ( [PARTITION BY expr[, ...]] [ORDER BY expr [ASC|DESC] [NULLS FIRST|LAST][, ...]] )
184
+ *
185
+ * @param {ParserState} state
186
+ * @param {number} positionStart - start position of the enclosing function call (for OrderByItem positions)
187
+ * @returns {{ partitionBy: ExprNode[], orderBy: OrderByItem[] }}
188
+ */
189
+ function parseWindowSpec(state, positionStart) {
190
+ expect(state, 'paren', '(')
191
+ /** @type {ExprNode[]} */
192
+ const partitionBy = []
193
+ /** @type {OrderByItem[]} */
194
+ const orderBy = []
195
+
196
+ const partitionTok = current(state)
197
+ if (partitionTok.type === 'identifier' && partitionTok.value.toUpperCase() === 'PARTITION') {
198
+ consume(state)
199
+ expect(state, 'keyword', 'BY')
200
+ while (true) {
201
+ partitionBy.push(parseExpression(state))
202
+ if (!match(state, 'comma')) break
203
+ }
204
+ }
205
+
206
+ if (match(state, 'keyword', 'ORDER')) {
207
+ expect(state, 'keyword', 'BY')
208
+ while (true) {
209
+ const expr = parseExpression(state)
210
+ /** @type {'ASC' | 'DESC'} */
211
+ let direction = 'ASC'
212
+ if (match(state, 'keyword', 'ASC')) {
213
+ direction = 'ASC'
214
+ } else if (match(state, 'keyword', 'DESC')) {
215
+ direction = 'DESC'
216
+ }
217
+ /** @type {'FIRST' | 'LAST' | undefined} */
218
+ let nulls
219
+ if (match(state, 'keyword', 'NULLS')) {
220
+ const tok = consume(state)
221
+ const upper = tok.value.toUpperCase()
222
+ if (tok.type === 'identifier' && upper === 'FIRST') {
223
+ nulls = 'FIRST'
224
+ } else if (tok.type === 'identifier' && upper === 'LAST') {
225
+ nulls = 'LAST'
226
+ } else {
227
+ throw new ParseError({
228
+ message: `Expected FIRST or LAST after NULLS at position ${tok.positionStart}`,
229
+ positionStart: tok.positionStart,
230
+ positionEnd: tok.positionEnd,
231
+ })
232
+ }
233
+ }
234
+ orderBy.push({ expr, direction, nulls, positionStart, positionEnd: state.lastPos })
235
+ if (!match(state, 'comma')) break
236
+ }
237
+ }
238
+
239
+ expect(state, 'paren', ')')
240
+ return { partitionBy, orderBy }
241
+ }
@@ -211,6 +211,10 @@ function collectColumnsFromExpr(expr, columns, aliases) {
211
211
  collectColumnsFromExpr(arg, columns, aliases)
212
212
  }
213
213
  collectColumnsFromExpr(expr.filter, columns, aliases)
214
+ } else if (expr.type === 'window') {
215
+ for (const arg of expr.args) collectColumnsFromExpr(arg, columns, aliases)
216
+ for (const p of expr.partitionBy) collectColumnsFromExpr(p, columns, aliases)
217
+ for (const o of expr.orderBy) collectColumnsFromExpr(o.expr, columns, aliases)
214
218
  } else if (expr.type === 'cast') {
215
219
  collectColumnsFromExpr(expr.expr, columns, aliases)
216
220
  } else if (expr.type === 'in valuelist') {
package/src/plan/plan.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import { derivedAlias } from '../expression/alias.js'
2
2
  import { parseSql } from '../parse/parse.js'
3
3
  import { findAggregate } from '../validation/aggregates.js'
4
+ import { ParseError } from '../validation/parseErrors.js'
4
5
  import { ColumnNotFoundError, TableNotFoundError } from '../validation/tables.js'
5
6
  import { validateNoIdentifiers, validateScan, validateTableRefs } from '../validation/tables.js'
6
7
  import { extractColumns, fromAlias, inferSelectSourceColumns, inferStatementColumns, tableFunctionColumnNames } from './columns.js'
7
8
 
8
9
  /**
9
- * @import { AsyncDataSource, ExprNode, DerivedColumn, IdentifierNode, JoinClause, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement, SetOperationStatement, Statement } from '../types.js'
10
- * @import { QueryPlan } from './types.d.ts'
10
+ * @import { AsyncDataSource, ExprNode, DerivedColumn, IdentifierNode, JoinClause, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement, SetOperationStatement, Statement, WindowFunctionNode } from '../types.js'
11
+ * @import { QueryPlan, WindowSpec } from './types.d.ts'
11
12
  */
12
13
 
13
14
  /**
@@ -106,12 +107,51 @@ function planSetOperation({ compound, ctePlans, cteColumns, tables, parentColumn
106
107
  * @returns {QueryPlan}
107
108
  */
108
109
  function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outerScope }) {
110
+ // Reject window functions in clauses where they're not permitted.
111
+ expectNoWindowFunction(select.where, 'WHERE')
112
+ expectNoWindowFunction(select.having, 'HAVING')
113
+ for (const expr of select.groupBy) expectNoWindowFunction(expr, 'GROUP BY')
114
+ for (const term of select.orderBy) expectNoWindowFunction(term.expr, 'ORDER BY')
115
+ for (const join of select.joins) expectNoWindowFunction(join.on, 'JOIN ON')
116
+
117
+ // Collect window functions from SELECT columns and rewrite them to identifiers
118
+ // pointing at the synthetic cells produced by the Window plan node.
119
+ /** @type {WindowSpec[]} */
120
+ const windows = []
121
+ const windowColumns = select.columns.map(col => {
122
+ if (col.type !== 'derived') return col
123
+ const originalAlias = col.alias ?? derivedAlias(col.expr)
124
+ const expr = collectWindows(col.expr, windows)
125
+ if (expr === col.expr) return col
126
+ return { ...col, expr, alias: originalAlias }
127
+ })
128
+
129
+ if (windows.length && select.columns.some(col => col.type === 'derived' && findAggregate(col.expr))) {
130
+ throw new ParseError({
131
+ message: 'Window functions are not supported in queries with aggregation',
132
+ ...select,
133
+ })
134
+ }
135
+ if (windows.length && select.groupBy.length) {
136
+ throw new ParseError({
137
+ message: 'Window functions are not supported in queries with aggregation',
138
+ ...select,
139
+ })
140
+ }
141
+
142
+ // Preserve the pre-substitution columns for column-extraction, so synthetic
143
+ // `__window_N` identifiers are not requested from the data source.
144
+ const originalSelect = select
145
+ select = { ...select, columns: windowColumns }
146
+
109
147
  // Check for aggregation
110
148
  const hasAggregate = select.columns.some(col =>
111
149
  col.type === 'derived' && findAggregate(col.expr)
112
150
  )
113
151
  const useGrouping = hasAggregate || select.groupBy.length > 0
114
- const needsBuffering = useGrouping || select.orderBy.length > 0
152
+ // Windows with PARTITION BY or ORDER BY buffer; `OVER ()` streams.
153
+ const bufferingWindows = windows.some(w => w.partitionBy.length > 0 || w.orderBy.length > 0)
154
+ const needsBuffering = useGrouping || select.orderBy.length > 0 || bufferingWindows
115
155
 
116
156
  // Source alias for FROM clause
117
157
  const sourceAlias = fromAlias(select.from)
@@ -155,7 +195,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
155
195
  // included so they are only applied to fresh scans, not CTE/subquery plans)
156
196
  /** @type {ScanOptions} */
157
197
  const hints = {}
158
- const perTableColumns = extractColumns({ select, parentColumns })
198
+ const perTableColumns = extractColumns({ select: originalSelect, parentColumns })
159
199
  hints.columns = perTableColumns.get(sourceAlias)
160
200
  // Empty columns array means no columns were referenced, but a FROM subquery
161
201
  // still needs its own columns (e.g. for DISTINCT). Treat empty as unrestricted.
@@ -219,6 +259,12 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
219
259
  } else {
220
260
  // Non-aggregation path
221
261
 
262
+ // Window functions: insert before Sort so outer ORDER BY can reference
263
+ // the window output aliases.
264
+ if (windows.length) {
265
+ plan = { type: 'Window', windows, child: plan }
266
+ }
267
+
222
268
  // ORDER BY (before projection so it can access all columns)
223
269
  // Resolve SELECT aliases in ORDER BY expressions at plan time
224
270
  if (select.orderBy.length) {
@@ -609,6 +655,126 @@ function validateLateralSubqueries({ expr, ctePlans, cteColumns, tables, outerSc
609
655
  }
610
656
  }
611
657
 
658
+ /**
659
+ * Walks an expression, replacing every window function subnode with an
660
+ * identifier that points at a synthetic `__window_N` cell. The collected
661
+ * WindowSpec entries drive the Window plan node. Returns the same node
662
+ * reference when no window function is present, so untouched expressions
663
+ * aren't shallow-cloned.
664
+ *
665
+ * @param {ExprNode} expr
666
+ * @param {WindowSpec[]} windows
667
+ * @returns {ExprNode}
668
+ */
669
+ function collectWindows(expr, windows) {
670
+ if (!expr || !findWindow(expr)) return expr
671
+ if (expr.type === 'window') {
672
+ const alias = `__window_${windows.length}`
673
+ windows.push({
674
+ alias,
675
+ funcName: expr.funcName.toUpperCase(),
676
+ args: expr.args,
677
+ partitionBy: expr.partitionBy,
678
+ orderBy: expr.orderBy,
679
+ })
680
+ return {
681
+ type: 'identifier',
682
+ name: alias,
683
+ positionStart: expr.positionStart,
684
+ positionEnd: expr.positionEnd,
685
+ }
686
+ }
687
+ if (expr.type === 'unary') {
688
+ return { ...expr, argument: collectWindows(expr.argument, windows) }
689
+ }
690
+ if (expr.type === 'binary') {
691
+ return { ...expr, left: collectWindows(expr.left, windows), right: collectWindows(expr.right, windows) }
692
+ }
693
+ if (expr.type === 'function') {
694
+ return { ...expr, args: expr.args.map(a => collectWindows(a, windows)) }
695
+ }
696
+ if (expr.type === 'cast') {
697
+ return { ...expr, expr: collectWindows(expr.expr, windows) }
698
+ }
699
+ if (expr.type === 'in valuelist') {
700
+ return {
701
+ ...expr,
702
+ expr: collectWindows(expr.expr, windows),
703
+ values: expr.values.map(v => collectWindows(v, windows)),
704
+ }
705
+ }
706
+ if (expr.type === 'case') {
707
+ return {
708
+ ...expr,
709
+ caseExpr: expr.caseExpr && collectWindows(expr.caseExpr, windows),
710
+ whenClauses: expr.whenClauses.map(w => ({
711
+ ...w,
712
+ condition: collectWindows(w.condition, windows),
713
+ result: collectWindows(w.result, windows),
714
+ })),
715
+ elseResult: expr.elseResult && collectWindows(expr.elseResult, windows),
716
+ }
717
+ }
718
+ return expr
719
+ }
720
+
721
+ /**
722
+ * Throws if the expression tree contains a window function.
723
+ *
724
+ * @param {ExprNode | undefined} expr
725
+ * @param {string} clause
726
+ */
727
+ function expectNoWindowFunction(expr, clause) {
728
+ const win = findWindow(expr)
729
+ if (win) {
730
+ throw new ParseError({
731
+ message: `Window function ${win.funcName.toUpperCase()} is not allowed in ${clause} clause`,
732
+ positionStart: win.positionStart,
733
+ positionEnd: win.positionEnd,
734
+ })
735
+ }
736
+ }
737
+
738
+ /**
739
+ * @param {ExprNode | undefined} expr
740
+ * @returns {WindowFunctionNode | undefined}
741
+ */
742
+ function findWindow(expr) {
743
+ if (!expr) return undefined
744
+ if (expr.type === 'window') return expr
745
+ if (expr.type === 'binary') return findWindow(expr.left) || findWindow(expr.right)
746
+ if (expr.type === 'unary') return findWindow(expr.argument)
747
+ if (expr.type === 'function') {
748
+ for (const arg of expr.args) {
749
+ const found = findWindow(arg)
750
+ if (found) return found
751
+ }
752
+ return undefined
753
+ }
754
+ if (expr.type === 'cast') return findWindow(expr.expr)
755
+ if (expr.type === 'in valuelist') {
756
+ const found = findWindow(expr.expr)
757
+ if (found) return found
758
+ for (const val of expr.values) {
759
+ const f = findWindow(val)
760
+ if (f) return f
761
+ }
762
+ return undefined
763
+ }
764
+ if (expr.type === 'case') {
765
+ if (expr.caseExpr) {
766
+ const f = findWindow(expr.caseExpr)
767
+ if (f) return f
768
+ }
769
+ for (const w of expr.whenClauses) {
770
+ const f = findWindow(w.condition) || findWindow(w.result)
771
+ if (f) return f
772
+ }
773
+ if (expr.elseResult) return findWindow(expr.elseResult)
774
+ }
775
+ return undefined
776
+ }
777
+
612
778
  /**
613
779
  * Checks if every SELECT column is a plain COUNT(*).
614
780
  *
@@ -15,6 +15,7 @@ export type QueryPlan =
15
15
  | PositionalJoinNode
16
16
  | SetOperationNode
17
17
  | TableFunctionNode
18
+ | WindowNode
18
19
 
19
20
  // Scan node
20
21
  export interface ScanNode {
@@ -124,3 +125,17 @@ export interface TableFunctionNode {
124
125
  args: ExprNode[]
125
126
  columnNames: string[]
126
127
  }
128
+
129
+ export interface WindowSpec {
130
+ alias: string
131
+ funcName: string
132
+ args: ExprNode[]
133
+ partitionBy: ExprNode[]
134
+ orderBy: OrderByItem[]
135
+ }
136
+
137
+ export interface WindowNode {
138
+ type: 'Window'
139
+ windows: WindowSpec[]
140
+ child: QueryPlan
141
+ }
@@ -26,6 +26,14 @@ export function isMathFunc(name) {
26
26
  ].includes(name)
27
27
  }
28
28
 
29
+ /**
30
+ * @param {string} name
31
+ * @returns {boolean}
32
+ */
33
+ export function isWindowFunc(name) {
34
+ return ['ROW_NUMBER'].includes(name)
35
+ }
36
+
29
37
  /**
30
38
  * @param {string} name
31
39
  * @returns {name is RegExpFunction}
@@ -200,6 +208,9 @@ export const FUNCTION_SIGNATURES = {
200
208
  APPROX_QUANTILE: { min: 2, max: 2, signature: 'expression, fraction' },
201
209
  STRING_AGG: { min: 2, max: 2, signature: 'expression, separator' },
202
210
 
211
+ // Window functions
212
+ ROW_NUMBER: { min: 0, max: 0, signature: '' },
213
+
203
214
  // Spatial functions
204
215
  ST_INTERSECTS: { min: 2, max: 2, signature: 'geometry, geometry' },
205
216
  ST_CONTAINS: { min: 2, max: 2, signature: 'geometry, geometry' },
@@ -2,7 +2,7 @@ import { FUNCTION_SIGNATURES } from './functions.js'
2
2
 
3
3
  /** Well-known window functions that are not supported */
4
4
  const WINDOW_FUNCTIONS = new Set([
5
- 'ROW_NUMBER', 'RANK', 'DENSE_RANK', 'NTILE',
5
+ 'RANK', 'DENSE_RANK', 'NTILE',
6
6
  'LAG', 'LEAD', 'FIRST_VALUE', 'LAST_VALUE', 'NTH_VALUE',
7
7
  'CUME_DIST', 'PERCENT_RANK',
8
8
  ])
@@ -119,6 +119,10 @@ export function validateTableRefs(expr, tables) {
119
119
  for (const arg of expr.args) {
120
120
  validateTableRefs(arg, tables)
121
121
  }
122
+ } else if (expr.type === 'window') {
123
+ for (const arg of expr.args) validateTableRefs(arg, tables)
124
+ for (const p of expr.partitionBy) validateTableRefs(p, tables)
125
+ for (const o of expr.orderBy) validateTableRefs(o.expr, tables)
122
126
  } else if (expr.type === 'cast') {
123
127
  validateTableRefs(expr.expr, tables)
124
128
  } else if (expr.type === 'in valuelist') {