squirreling 0.12.7 → 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/README.md +3 -3
- package/package.json +1 -1
- package/src/ast.d.ts +10 -1
- package/src/execute/execute.js +78 -7
- package/src/execute/join.js +1 -1
- package/src/execute/window.js +154 -0
- package/src/expression/alias.js +3 -0
- package/src/expression/evaluate.js +35 -1
- package/src/parse/functions.js +121 -6
- package/src/parse/parse.js +33 -7
- package/src/plan/columns.js +25 -9
- package/src/plan/plan.js +172 -6
- package/src/plan/types.d.ts +16 -1
- package/src/types.d.ts +1 -1
- package/src/validation/functions.js +17 -2
- package/src/validation/keywords.js +1 -1
- package/src/validation/parseErrors.js +1 -1
- package/src/validation/tables.js +4 -0
package/README.md
CHANGED
|
@@ -154,14 +154,14 @@ Squirreling mostly follows the SQL standard. The following features are supporte
|
|
|
154
154
|
|
|
155
155
|
### Functions
|
|
156
156
|
|
|
157
|
-
- Aggregate: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `MEDIAN`, `PERCENTILE_CONT`, `APPROX_QUANTILE`, `STDDEV_POP`, `STDDEV_SAMP`, `JSON_ARRAYAGG`, `STRING_AGG`
|
|
157
|
+
- Aggregate: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `MEDIAN`, `PERCENTILE_CONT`, `APPROX_QUANTILE`, `STDDEV_POP`, `STDDEV_SAMP`, `ARRAY_AGG`, `JSON_ARRAYAGG`, `STRING_AGG`
|
|
158
158
|
- String: `CONCAT`, `SUBSTRING`, `REPLACE`, `LENGTH`, `UPPER`, `LOWER`, `TRIM`, `LEFT`, `RIGHT`, `INSTR`, `POSITION`, `STRPOS`
|
|
159
159
|
- Math: `ABS`, `SIGN`, `CEIL`, `FLOOR`, `ROUND`, `MOD`, `RAND`, `RANDOM`, `LN`, `LOG10`, `EXP`, `POWER`, `SQRT`
|
|
160
160
|
- Trig: `SIN`, `COS`, `TAN`, `COT`, `ASIN`, `ACOS`, `ATAN`, `ATAN2`, `DEGREES`, `RADIANS`, `PI`
|
|
161
161
|
- Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `DATE_PART`, `DATE_TRUNC`, `EXTRACT`, `INTERVAL`
|
|
162
|
-
- Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_OBJECT`, `JSON_ARRAY_LENGTH`
|
|
162
|
+
- Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_OBJECT`, `JSON_ARRAY_LENGTH`, `JSON_VALID`, `JSON_TYPE`
|
|
163
163
|
- Array: `ARRAY_LENGTH`, `ARRAY_POSITION`, `ARRAY_SORT`, `CARDINALITY`
|
|
164
|
-
- Table functions: `UNNEST`
|
|
164
|
+
- Table functions: `UNNEST`, `JSON_EACH`
|
|
165
165
|
- Regex: `REGEXP_SUBSTR`, `REGEXP_EXTRACT`, `REGEXP_REPLACE`, `REGEXP_MATCHES`
|
|
166
166
|
- Spatial: `ST_GeomFromText`, `ST_MakeEnvelope`, `ST_AsText`, `ST_Intersects`, `ST_Contains`, `ST_ContainsProperly`, `ST_Within`, `ST_Overlaps`, `ST_Touches`, `ST_Equals`, `ST_Crosses`, `ST_Covers`, `ST_CoveredBy`, `ST_DWithin`
|
|
167
167
|
- Conditional: `COALESCE`, `NULLIF`, `GREATEST`, `LEAST`
|
package/package.json
CHANGED
package/src/ast.d.ts
CHANGED
|
@@ -65,7 +65,7 @@ export interface FromFunction extends AstBase {
|
|
|
65
65
|
funcName: string
|
|
66
66
|
args: ExprNode[]
|
|
67
67
|
alias?: string
|
|
68
|
-
|
|
68
|
+
columnAliases: string[]
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
export type ArithmeticOp = '+' | '-' | '*' | '/' | '%'
|
|
@@ -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,24 +121,38 @@ 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
|
}
|
|
126
129
|
|
|
127
130
|
/**
|
|
128
|
-
* Executes a table-valued function (e.g. UNNEST).
|
|
129
|
-
* Evaluates the argument once against
|
|
130
|
-
*
|
|
131
|
+
* Executes a table-valued function (e.g. UNNEST, JSON_EACH).
|
|
132
|
+
* Evaluates the argument once against the outer row (for lateral joins) or an
|
|
133
|
+
* empty row, then yields rows derived from the resulting value.
|
|
131
134
|
*
|
|
132
135
|
* @param {TableFunctionNode} plan
|
|
133
136
|
* @param {ExecuteContext} context
|
|
134
137
|
* @returns {QueryResults}
|
|
135
138
|
*/
|
|
136
139
|
function executeTableFunction(plan, context) {
|
|
137
|
-
if (plan.funcName
|
|
138
|
-
|
|
140
|
+
if (plan.funcName === 'UNNEST') {
|
|
141
|
+
return executeUnnest(plan, context)
|
|
142
|
+
} else if (plan.funcName === 'JSON_EACH') {
|
|
143
|
+
return executeJsonEach(plan, context)
|
|
139
144
|
}
|
|
140
|
-
|
|
145
|
+
throw new Error(`Unsupported table function: ${plan.funcName}`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {TableFunctionNode} plan
|
|
150
|
+
* @param {ExecuteContext} context
|
|
151
|
+
* @returns {QueryResults}
|
|
152
|
+
*/
|
|
153
|
+
function executeUnnest(plan, context) {
|
|
154
|
+
const columns = plan.columnNames
|
|
155
|
+
const [columnName] = columns
|
|
141
156
|
return {
|
|
142
157
|
columns,
|
|
143
158
|
async *rows() {
|
|
@@ -149,9 +164,65 @@ function executeTableFunction(plan, context) {
|
|
|
149
164
|
if (context.signal?.aborted) return
|
|
150
165
|
yield {
|
|
151
166
|
columns,
|
|
152
|
-
cells: { [
|
|
167
|
+
cells: { [columnName]: () => Promise.resolve(element) },
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {TableFunctionNode} plan
|
|
176
|
+
* @param {ExecuteContext} context
|
|
177
|
+
* @returns {QueryResults}
|
|
178
|
+
*/
|
|
179
|
+
function executeJsonEach(plan, context) {
|
|
180
|
+
const columns = plan.columnNames
|
|
181
|
+
const [keyCol, valueCol] = columns
|
|
182
|
+
return {
|
|
183
|
+
columns,
|
|
184
|
+
async *rows() {
|
|
185
|
+
/** @type {AsyncRow} */
|
|
186
|
+
const row = context.outerRow ?? { columns: [], cells: {} }
|
|
187
|
+
const value = await evaluateExpr({ node: plan.args[0], row, rowIndex: 1, context })
|
|
188
|
+
if (value == null) return
|
|
189
|
+
let parsed = value
|
|
190
|
+
if (typeof value === 'string') {
|
|
191
|
+
try {
|
|
192
|
+
parsed = JSON.parse(value)
|
|
193
|
+
} catch {
|
|
194
|
+
throw new Error('JSON_EACH(value): invalid JSON string. Argument must be valid JSON.')
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (Array.isArray(parsed)) {
|
|
198
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
199
|
+
if (context.signal?.aborted) return
|
|
200
|
+
const k = i
|
|
201
|
+
const v = parsed[i]
|
|
202
|
+
yield {
|
|
203
|
+
columns,
|
|
204
|
+
cells: {
|
|
205
|
+
[keyCol]: () => Promise.resolve(k),
|
|
206
|
+
[valueCol]: () => Promise.resolve(v),
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
213
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
214
|
+
if (context.signal?.aborted) return
|
|
215
|
+
yield {
|
|
216
|
+
columns,
|
|
217
|
+
cells: {
|
|
218
|
+
[keyCol]: () => Promise.resolve(k),
|
|
219
|
+
[valueCol]: () => Promise.resolve(v),
|
|
220
|
+
},
|
|
221
|
+
}
|
|
153
222
|
}
|
|
223
|
+
return
|
|
154
224
|
}
|
|
225
|
+
throw new Error('JSON_EACH(value): argument must be a JSON object or array')
|
|
155
226
|
},
|
|
156
227
|
}
|
|
157
228
|
}
|
package/src/execute/join.js
CHANGED
|
@@ -95,7 +95,7 @@ export function executeNestedLoopJoin(plan, context) {
|
|
|
95
95
|
function executeLateralJoin(plan, context) {
|
|
96
96
|
const left = executePlan({ plan: plan.left, context })
|
|
97
97
|
// Right columns are known statically for table functions (the common case).
|
|
98
|
-
const rightCols = plan.right.type === 'TableFunction' ?
|
|
98
|
+
const rightCols = plan.right.type === 'TableFunction' ? plan.right.columnNames : []
|
|
99
99
|
return {
|
|
100
100
|
columns: mergeColumnNames(left.columns, rightCols, plan.leftAlias, plan.rightAlias),
|
|
101
101
|
async *rows() {
|
|
@@ -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
|
}
|
|
@@ -272,7 +272,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
272
272
|
return values[lower] + (values[upper] - values[lower]) * (pos - lower)
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
if (funcName === 'JSON_ARRAYAGG') {
|
|
275
|
+
if (funcName === 'JSON_ARRAYAGG' || funcName === 'ARRAY_AGG') {
|
|
276
276
|
if (node.distinct) {
|
|
277
277
|
/** @type {SqlPrimitive[]} */
|
|
278
278
|
const values = []
|
|
@@ -417,6 +417,40 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
417
417
|
return result
|
|
418
418
|
}
|
|
419
419
|
|
|
420
|
+
if (funcName === 'JSON_VALID') {
|
|
421
|
+
const value = args[0]
|
|
422
|
+
if (value == null) return null
|
|
423
|
+
if (typeof value !== 'string') return false
|
|
424
|
+
try {
|
|
425
|
+
JSON.parse(value)
|
|
426
|
+
return true
|
|
427
|
+
} catch {
|
|
428
|
+
return false
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (funcName === 'JSON_TYPE') {
|
|
433
|
+
let value = args[0]
|
|
434
|
+
if (value == null) return null
|
|
435
|
+
if (typeof value === 'string') {
|
|
436
|
+
try {
|
|
437
|
+
value = JSON.parse(value)
|
|
438
|
+
} catch {
|
|
439
|
+
throw new ArgValueError({
|
|
440
|
+
...node,
|
|
441
|
+
message: 'invalid JSON string',
|
|
442
|
+
hint: 'Argument must be valid JSON.',
|
|
443
|
+
rowIndex,
|
|
444
|
+
})
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (value === null) return 'null'
|
|
448
|
+
if (Array.isArray(value)) return 'array'
|
|
449
|
+
if (value instanceof Date) return 'string'
|
|
450
|
+
if (typeof value === 'bigint') return 'number'
|
|
451
|
+
return typeof value
|
|
452
|
+
}
|
|
453
|
+
|
|
420
454
|
if (funcName === 'JSON_ARRAY_LENGTH') {
|
|
421
455
|
let arr = args[0]
|
|
422
456
|
if (arr == null) return null
|
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
|
/**
|
|
@@ -84,6 +84,30 @@ export function parseFunctionCall(state, positionStart) {
|
|
|
84
84
|
})
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// Check for WITHIN GROUP (ORDER BY expr) clause — standard SQL ordered-set aggregate syntax.
|
|
88
|
+
// Supported for PERCENTILE_CONT: PERCENTILE_CONT(fraction) WITHIN GROUP (ORDER BY expr)
|
|
89
|
+
const withinTok = current(state)
|
|
90
|
+
if (match(state, 'keyword', 'WITHIN')) {
|
|
91
|
+
if (funcNameUpper !== 'PERCENTILE_CONT') {
|
|
92
|
+
throw new ParseError({
|
|
93
|
+
message: `WITHIN GROUP is only supported for PERCENTILE_CONT, not "${funcName}"`,
|
|
94
|
+
...withinTok,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
if (args.length !== 1) {
|
|
98
|
+
throw new ParseError({
|
|
99
|
+
message: `${funcName}: cannot combine WITHIN GROUP with a value argument`,
|
|
100
|
+
...withinTok,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
expect(state, 'keyword', 'GROUP')
|
|
104
|
+
expect(state, 'paren', '(')
|
|
105
|
+
expect(state, 'keyword', 'ORDER')
|
|
106
|
+
expect(state, 'keyword', 'BY')
|
|
107
|
+
args.push(parseExpression(state))
|
|
108
|
+
expect(state, 'paren', ')')
|
|
109
|
+
}
|
|
110
|
+
|
|
87
111
|
// Validate argument count at parse time
|
|
88
112
|
validateFunctionArgs(funcNameUpper, args.length, positionStart, state.lastPos, state.functions)
|
|
89
113
|
|
|
@@ -104,13 +128,43 @@ export function parseFunctionCall(state, positionStart) {
|
|
|
104
128
|
expect(state, 'paren', ')')
|
|
105
129
|
}
|
|
106
130
|
|
|
107
|
-
// Check for OVER clause
|
|
131
|
+
// Check for OVER clause
|
|
108
132
|
const overTok = current(state)
|
|
109
|
-
|
|
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)) {
|
|
110
164
|
throw new ParseError({
|
|
111
|
-
message:
|
|
165
|
+
message: `${funcName}() requires an OVER clause at position ${positionStart}`,
|
|
112
166
|
positionStart,
|
|
113
|
-
positionEnd:
|
|
167
|
+
positionEnd: state.lastPos,
|
|
114
168
|
})
|
|
115
169
|
}
|
|
116
170
|
|
|
@@ -124,3 +178,64 @@ export function parseFunctionCall(state, positionStart) {
|
|
|
124
178
|
positionEnd: state.lastPos,
|
|
125
179
|
}
|
|
126
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/parse/parse.js
CHANGED
|
@@ -468,15 +468,22 @@ export function parseFromFunction(state) {
|
|
|
468
468
|
validateFunctionArgs(funcName, args.length, positionStart, state.lastPos, state.functions)
|
|
469
469
|
|
|
470
470
|
const alias = parseTableAlias(state)
|
|
471
|
-
/** @type {string
|
|
472
|
-
|
|
471
|
+
/** @type {string[]} */
|
|
472
|
+
const columnAliases = []
|
|
473
473
|
if (alias && match(state, 'paren', '(')) {
|
|
474
474
|
const colStart = state.lastPos
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
475
|
+
while (true) {
|
|
476
|
+
const colTok = expect(state, 'identifier')
|
|
477
|
+
columnAliases.push(colTok.value)
|
|
478
|
+
if (!match(state, 'comma')) break
|
|
479
|
+
}
|
|
480
|
+
const maxCols = tableFunctionColumnCount(funcName)
|
|
481
|
+
if (columnAliases.length > maxCols) {
|
|
482
|
+
const colLabels = tableFunctionDefaultColumns(funcName).join(', ')
|
|
478
483
|
throw new ParseError({
|
|
479
|
-
message:
|
|
484
|
+
message: maxCols === 1
|
|
485
|
+
? `${funcName} produces a single column; only one column alias is allowed`
|
|
486
|
+
: `${funcName} produces at most ${maxCols} columns (${colLabels}); too many column aliases`,
|
|
480
487
|
positionStart: colStart,
|
|
481
488
|
positionEnd: state.lastPos,
|
|
482
489
|
})
|
|
@@ -489,12 +496,31 @@ export function parseFromFunction(state) {
|
|
|
489
496
|
funcName,
|
|
490
497
|
args,
|
|
491
498
|
alias,
|
|
492
|
-
|
|
499
|
+
columnAliases,
|
|
493
500
|
positionStart,
|
|
494
501
|
positionEnd: state.lastPos,
|
|
495
502
|
}
|
|
496
503
|
}
|
|
497
504
|
|
|
505
|
+
/**
|
|
506
|
+
* Default column names produced by a table-valued function.
|
|
507
|
+
* @param {string} funcName
|
|
508
|
+
* @returns {string[]}
|
|
509
|
+
*/
|
|
510
|
+
export function tableFunctionDefaultColumns(funcName) {
|
|
511
|
+
if (funcName === 'JSON_EACH') return ['key', 'value']
|
|
512
|
+
return [funcName.toLowerCase()]
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Maximum number of output columns a table-valued function can produce.
|
|
517
|
+
* @param {string} funcName
|
|
518
|
+
* @returns {number}
|
|
519
|
+
*/
|
|
520
|
+
export function tableFunctionColumnCount(funcName) {
|
|
521
|
+
return tableFunctionDefaultColumns(funcName).length
|
|
522
|
+
}
|
|
523
|
+
|
|
498
524
|
/**
|
|
499
525
|
* Parses an optional table alias (e.g., "FROM users u" or "FROM users AS u")
|
|
500
526
|
* @param {ParserState} state
|
package/src/plan/columns.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { tableFunctionDefaultColumns } from '../parse/parse.js'
|
|
1
2
|
import { derivedAlias } from '../expression/alias.js'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -18,13 +19,19 @@ export function fromAlias(from) {
|
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
|
-
* Returns the
|
|
22
|
+
* Returns the output column names for a FROM table function, applying any
|
|
23
|
+
* column aliases over the function's default column names.
|
|
22
24
|
*
|
|
23
25
|
* @param {FromFunction} from
|
|
24
|
-
* @returns {string}
|
|
26
|
+
* @returns {string[]}
|
|
25
27
|
*/
|
|
26
|
-
export function
|
|
27
|
-
|
|
28
|
+
export function tableFunctionColumnNames(from) {
|
|
29
|
+
const defaults = tableFunctionDefaultColumns(from.funcName)
|
|
30
|
+
const result = []
|
|
31
|
+
for (let i = 0; i < defaults.length; i++) {
|
|
32
|
+
result.push(from.columnAliases[i] ?? defaults[i])
|
|
33
|
+
}
|
|
34
|
+
return result
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
/**
|
|
@@ -204,6 +211,10 @@ function collectColumnsFromExpr(expr, columns, aliases) {
|
|
|
204
211
|
collectColumnsFromExpr(arg, columns, aliases)
|
|
205
212
|
}
|
|
206
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)
|
|
207
218
|
} else if (expr.type === 'cast') {
|
|
208
219
|
collectColumnsFromExpr(expr.expr, columns, aliases)
|
|
209
220
|
} else if (expr.type === 'in valuelist') {
|
|
@@ -330,18 +341,21 @@ export function inferSelectSourceColumns({ select, cteColumns, tables }) {
|
|
|
330
341
|
}
|
|
331
342
|
|
|
332
343
|
if (select.from.type === 'function') {
|
|
333
|
-
// Table functions currently produce a single column
|
|
334
344
|
if (!select.joins.length) {
|
|
335
|
-
return
|
|
345
|
+
return tableFunctionColumnNames(select.from)
|
|
336
346
|
}
|
|
337
347
|
/** @type {string[]} */
|
|
338
348
|
const result = []
|
|
339
349
|
const alias = fromAlias(select.from)
|
|
340
|
-
|
|
350
|
+
for (const col of tableFunctionColumnNames(select.from)) {
|
|
351
|
+
result.push(`${alias}.${col}`)
|
|
352
|
+
}
|
|
341
353
|
for (const join of select.joins) {
|
|
342
354
|
const joinAlias = join.alias ?? join.table
|
|
343
355
|
if (join.fromFunction) {
|
|
344
|
-
|
|
356
|
+
for (const col of tableFunctionColumnNames(join.fromFunction)) {
|
|
357
|
+
result.push(`${joinAlias}.${col}`)
|
|
358
|
+
}
|
|
345
359
|
} else {
|
|
346
360
|
for (const col of lookupTableColumns(join.table, cteColumns, tables)) {
|
|
347
361
|
result.push(`${joinAlias}.${col}`)
|
|
@@ -365,7 +379,9 @@ export function inferSelectSourceColumns({ select, cteColumns, tables }) {
|
|
|
365
379
|
for (const join of select.joins) {
|
|
366
380
|
const joinAlias = join.alias ?? join.table
|
|
367
381
|
if (join.fromFunction) {
|
|
368
|
-
|
|
382
|
+
for (const col of tableFunctionColumnNames(join.fromFunction)) {
|
|
383
|
+
result.push(`${joinAlias}.${col}`)
|
|
384
|
+
}
|
|
369
385
|
} else {
|
|
370
386
|
for (const col of lookupTableColumns(join.table, cteColumns, tables)) {
|
|
371
387
|
result.push(`${joinAlias}.${col}`)
|
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
|
-
import { extractColumns, fromAlias, inferSelectSourceColumns, inferStatementColumns,
|
|
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) {
|
|
@@ -321,7 +367,7 @@ function planTableFunction(from) {
|
|
|
321
367
|
type: 'TableFunction',
|
|
322
368
|
funcName: from.funcName,
|
|
323
369
|
args: from.args,
|
|
324
|
-
|
|
370
|
+
columnNames: tableFunctionColumnNames(from),
|
|
325
371
|
}
|
|
326
372
|
}
|
|
327
373
|
|
|
@@ -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 {
|
|
@@ -122,5 +123,19 @@ export interface TableFunctionNode {
|
|
|
122
123
|
type: 'TableFunction'
|
|
123
124
|
funcName: string
|
|
124
125
|
args: ExprNode[]
|
|
125
|
-
|
|
126
|
+
columnNames: string[]
|
|
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
|
|
126
141
|
}
|
package/src/types.d.ts
CHANGED
|
@@ -129,7 +129,7 @@ export interface UserDefinedFunction {
|
|
|
129
129
|
arguments: FunctionSignature
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
export type AggregateFunc = 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'JSON_ARRAYAGG' | 'STDDEV_SAMP' | 'STDDEV_POP' | 'MEDIAN' | 'PERCENTILE_CONT' | 'APPROX_QUANTILE' | 'STRING_AGG'
|
|
132
|
+
export type AggregateFunc = 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'ARRAY_AGG' | 'JSON_ARRAYAGG' | 'STDDEV_SAMP' | 'STDDEV_POP' | 'MEDIAN' | 'PERCENTILE_CONT' | 'APPROX_QUANTILE' | 'STRING_AGG'
|
|
133
133
|
|
|
134
134
|
export type RegExpFunction = 'REGEXP_SUBSTR' | 'REGEXP_EXTRACT' | 'REGEXP_REPLACE' | 'REGEXP_MATCHES'
|
|
135
135
|
|
|
@@ -11,7 +11,7 @@ export const niladicFuncs = ['CURRENT_DATE', 'CURRENT_TIME', 'CURRENT_TIMESTAMP'
|
|
|
11
11
|
* @returns {name is AggregateFunc}
|
|
12
12
|
*/
|
|
13
13
|
export function isAggregateFunc(name) {
|
|
14
|
-
return ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'JSON_ARRAYAGG', 'STDDEV_SAMP', 'STDDEV_POP', 'MEDIAN', 'PERCENTILE_CONT', 'APPROX_QUANTILE', 'STRING_AGG'].includes(name)
|
|
14
|
+
return ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'ARRAY_AGG', 'JSON_ARRAYAGG', 'STDDEV_SAMP', 'STDDEV_POP', 'MEDIAN', 'PERCENTILE_CONT', 'APPROX_QUANTILE', 'STRING_AGG'].includes(name)
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -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}
|
|
@@ -41,7 +49,7 @@ export function isRegexpFunc(name) {
|
|
|
41
49
|
* @returns {boolean}
|
|
42
50
|
*/
|
|
43
51
|
export function isTableFunction(name) {
|
|
44
|
-
return ['UNNEST'].includes(name)
|
|
52
|
+
return ['UNNEST', 'JSON_EACH'].includes(name)
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
/**
|
|
@@ -166,7 +174,10 @@ export const FUNCTION_SIGNATURES = {
|
|
|
166
174
|
JSON_EXTRACT: { min: 2, max: 2, signature: 'expression, path' },
|
|
167
175
|
JSON_OBJECT: { min: 0, signature: 'key1, value1[, ...]' },
|
|
168
176
|
JSON_ARRAY_LENGTH: { min: 1, max: 1, signature: 'array' },
|
|
177
|
+
JSON_VALID: { min: 1, max: 1, signature: 'value' },
|
|
178
|
+
JSON_TYPE: { min: 1, max: 1, signature: 'value' },
|
|
169
179
|
JSON_ARRAYAGG: { min: 1, max: 1, signature: 'expression' },
|
|
180
|
+
ARRAY_AGG: { min: 1, max: 1, signature: 'expression' },
|
|
170
181
|
|
|
171
182
|
// Array functions
|
|
172
183
|
ARRAY_LENGTH: { min: 1, max: 1, signature: 'array' },
|
|
@@ -176,6 +187,7 @@ export const FUNCTION_SIGNATURES = {
|
|
|
176
187
|
|
|
177
188
|
// Table functions (used in FROM clause)
|
|
178
189
|
UNNEST: { min: 1, max: 1, signature: 'array' },
|
|
190
|
+
JSON_EACH: { min: 1, max: 1, signature: 'value' },
|
|
179
191
|
|
|
180
192
|
// Conditional functions
|
|
181
193
|
COALESCE: { min: 1, signature: 'value1, value2[, ...]' },
|
|
@@ -196,6 +208,9 @@ export const FUNCTION_SIGNATURES = {
|
|
|
196
208
|
APPROX_QUANTILE: { min: 2, max: 2, signature: 'expression, fraction' },
|
|
197
209
|
STRING_AGG: { min: 2, max: 2, signature: 'expression, separator' },
|
|
198
210
|
|
|
211
|
+
// Window functions
|
|
212
|
+
ROW_NUMBER: { min: 0, max: 0, signature: '' },
|
|
213
|
+
|
|
199
214
|
// Spatial functions
|
|
200
215
|
ST_INTERSECTS: { min: 2, max: 2, signature: 'geometry, geometry' },
|
|
201
216
|
ST_CONTAINS: { min: 2, max: 2, signature: 'geometry, geometry' },
|
|
@@ -4,7 +4,7 @@ export const KEYWORDS = new Set([
|
|
|
4
4
|
'DISTINCT', 'TRUE', 'FALSE', 'NULL', 'LIKE', 'IN', 'EXISTS', 'BETWEEN',
|
|
5
5
|
'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'JOIN', 'INNER', 'LEFT', 'RIGHT',
|
|
6
6
|
'FULL', 'OUTER', 'CROSS', 'POSITIONAL', 'LATERAL', 'ON', 'INTERVAL', 'DAY', 'MONTH', 'YEAR',
|
|
7
|
-
'HOUR', 'MINUTE', 'SECOND', 'FILTER',
|
|
7
|
+
'HOUR', 'MINUTE', 'SECOND', 'FILTER', 'WITHIN',
|
|
8
8
|
'UNION', 'INTERSECT', 'EXCEPT',
|
|
9
9
|
])
|
|
10
10
|
|
|
@@ -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') {
|