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 +1 -1
- package/src/ast.d.ts +9 -0
- package/src/execute/execute.js +3 -0
- package/src/execute/window.js +154 -0
- package/src/expression/alias.js +3 -0
- package/src/parse/functions.js +97 -6
- package/src/plan/columns.js +4 -0
- package/src/plan/plan.js +170 -4
- package/src/plan/types.d.ts +15 -0
- package/src/validation/functions.js +11 -0
- package/src/validation/parseErrors.js +1 -1
- package/src/validation/tables.js +4 -0
package/package.json
CHANGED
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
|
package/src/execute/execute.js
CHANGED
|
@@ -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
|
+
}
|
package/src/expression/alias.js
CHANGED
|
@@ -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
|
}
|
package/src/parse/functions.js
CHANGED
|
@@ -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
|
|
131
|
+
// Check for OVER clause
|
|
132
132
|
const overTok = current(state)
|
|
133
|
-
|
|
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:
|
|
165
|
+
message: `${funcName}() requires an OVER clause at position ${positionStart}`,
|
|
136
166
|
positionStart,
|
|
137
|
-
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
|
+
}
|
package/src/plan/columns.js
CHANGED
|
@@ -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
|
-
|
|
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
|
*
|
package/src/plan/types.d.ts
CHANGED
|
@@ -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
|
-
'
|
|
5
|
+
'RANK', 'DENSE_RANK', 'NTILE',
|
|
6
6
|
'LAG', 'LEAD', 'FIRST_VALUE', 'LAST_VALUE', 'NTH_VALUE',
|
|
7
7
|
'CUME_DIST', 'PERCENT_RANK',
|
|
8
8
|
])
|
package/src/validation/tables.js
CHANGED
|
@@ -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') {
|