squirreling 0.11.3 → 0.11.5
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 +2 -2
- package/src/ast.d.ts +5 -4
- package/src/execute/aggregates.js +1 -1
- package/src/execute/execute.js +1 -1
- package/src/execute/join.js +13 -27
- package/src/expression/alias.js +0 -5
- package/src/expression/binary.js +1 -1
- package/src/expression/evaluate.js +15 -12
- package/src/expression/math.js +1 -1
- package/src/parse/parse.js +9 -6
- package/src/parse/primary.js +17 -10
- package/src/parse/state.js +8 -1
- package/src/parse/tokenize.js +29 -27
- package/src/parse/types.d.ts +2 -2
- package/src/plan/columns.js +15 -17
- package/src/plan/plan.js +31 -11
- package/src/types.d.ts +1 -0
- package/src/validation/functions.js +3 -2
- package/src/validation/parseErrors.js +19 -2
- package/src/validation/tables.js +62 -12
- package/src/validation/planErrors.js +0 -50
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squirreling",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.5",
|
|
4
4
|
"description": "Squirreling Async SQL Engine",
|
|
5
5
|
"author": "Hyperparam",
|
|
6
6
|
"homepage": "https://hyperparam.app",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"@types/node": "25.5.0",
|
|
43
43
|
"@vitest/coverage-v8": "4.1.2",
|
|
44
44
|
"eslint": "9.39.2",
|
|
45
|
-
"eslint-plugin-jsdoc": "62.
|
|
45
|
+
"eslint-plugin-jsdoc": "62.9.0",
|
|
46
46
|
"typescript": "6.0.2",
|
|
47
47
|
"vitest": "4.1.2"
|
|
48
48
|
}
|
package/src/ast.d.ts
CHANGED
|
@@ -64,7 +64,7 @@ export type ArithmeticOp = '+' | '-' | '*' | '/' | '%'
|
|
|
64
64
|
|
|
65
65
|
export type BinaryOp = 'AND' | 'OR' | 'LIKE' | ComparisonOp | ArithmeticOp
|
|
66
66
|
|
|
67
|
-
export type ComparisonOp = '=' | '!=' | '<>' | '<' | '>' | '<=' | '>='
|
|
67
|
+
export type ComparisonOp = '=' | '==' | '!=' | '<>' | '<' | '>' | '<=' | '>='
|
|
68
68
|
|
|
69
69
|
export interface LiteralNode extends AstBase {
|
|
70
70
|
type: 'literal'
|
|
@@ -74,6 +74,7 @@ export interface LiteralNode extends AstBase {
|
|
|
74
74
|
export interface IdentifierNode extends AstBase {
|
|
75
75
|
type: 'identifier'
|
|
76
76
|
name: string
|
|
77
|
+
prefix?: string
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
export interface UnaryNode extends AstBase {
|
|
@@ -166,12 +167,12 @@ export type ExprNode =
|
|
|
166
167
|
| IntervalNode
|
|
167
168
|
| StarNode
|
|
168
169
|
|
|
169
|
-
export interface StarColumn {
|
|
170
|
+
export interface StarColumn extends AstBase {
|
|
170
171
|
type: 'star'
|
|
171
172
|
table?: string
|
|
172
173
|
}
|
|
173
174
|
|
|
174
|
-
export interface DerivedColumn {
|
|
175
|
+
export interface DerivedColumn extends AstBase {
|
|
175
176
|
type: 'derived'
|
|
176
177
|
expr: ExprNode
|
|
177
178
|
alias?: string
|
|
@@ -179,7 +180,7 @@ export interface DerivedColumn {
|
|
|
179
180
|
|
|
180
181
|
export type SelectColumn = StarColumn | DerivedColumn
|
|
181
182
|
|
|
182
|
-
export interface OrderByItem {
|
|
183
|
+
export interface OrderByItem extends AstBase {
|
|
183
184
|
expr: ExprNode
|
|
184
185
|
direction: 'ASC' | 'DESC'
|
|
185
186
|
nulls?: 'FIRST' | 'LAST'
|
|
@@ -30,7 +30,7 @@ function projectAggregateColumns(selectColumns, group, context) {
|
|
|
30
30
|
for (const key of firstRow.columns) {
|
|
31
31
|
if (prefix && !key.startsWith(prefix)) continue
|
|
32
32
|
const dotIndex = key.indexOf('.')
|
|
33
|
-
const outputKey = dotIndex >= 0 ? key.substring(dotIndex + 1) : key
|
|
33
|
+
const outputKey = prefix ? key.substring(prefix.length) : dotIndex >= 0 ? key.substring(dotIndex + 1) : key
|
|
34
34
|
columns.push(outputKey)
|
|
35
35
|
cells[outputKey] = firstRow.cells[key]
|
|
36
36
|
}
|
package/src/execute/execute.js
CHANGED
|
@@ -290,7 +290,7 @@ async function* executeProject(plan, context) {
|
|
|
290
290
|
if (prefix && !key.startsWith(prefix)) continue
|
|
291
291
|
// Strip table prefix for output column names
|
|
292
292
|
const dotIndex = key.indexOf('.')
|
|
293
|
-
const outputKey = dotIndex >= 0 ? key.substring(dotIndex + 1) : key
|
|
293
|
+
const outputKey = prefix ? key.substring(prefix.length) : dotIndex >= 0 ? key.substring(dotIndex + 1) : key
|
|
294
294
|
columns.push(outputKey)
|
|
295
295
|
cells[outputKey] = row.cells[key]
|
|
296
296
|
}
|
package/src/execute/join.js
CHANGED
|
@@ -26,18 +26,18 @@ export async function* executeNestedLoopJoin(plan, context) {
|
|
|
26
26
|
rightRows.push(row)
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
const
|
|
29
|
+
const rightCols = rightRows.length ? rightRows[0].columns : []
|
|
30
30
|
|
|
31
31
|
/** @type {string[] | undefined} */
|
|
32
|
-
let
|
|
32
|
+
let leftCols = undefined
|
|
33
33
|
/** @type {Set<AsyncRow> | undefined} */
|
|
34
34
|
const matchedRightRows = plan.joinType === 'RIGHT' || plan.joinType === 'FULL' ? new Set() : undefined
|
|
35
35
|
|
|
36
36
|
for await (const leftRow of executePlan({ plan: plan.left, context })) {
|
|
37
37
|
if (context.signal?.aborted) break
|
|
38
38
|
|
|
39
|
-
if (!
|
|
40
|
-
|
|
39
|
+
if (!leftCols) {
|
|
40
|
+
leftCols = leftRow.columns
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
let hasMatch = false
|
|
@@ -58,7 +58,7 @@ export async function* executeNestedLoopJoin(plan, context) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
if (!hasMatch && (plan.joinType === 'LEFT' || plan.joinType === 'FULL')) {
|
|
61
|
-
const nullRight = createNullRow(
|
|
61
|
+
const nullRight = createNullRow(rightCols)
|
|
62
62
|
yield mergeRows(leftRow, nullRight, leftTable, rightTable)
|
|
63
63
|
}
|
|
64
64
|
}
|
|
@@ -67,7 +67,7 @@ export async function* executeNestedLoopJoin(plan, context) {
|
|
|
67
67
|
if (matchedRightRows) {
|
|
68
68
|
for (const rightRow of rightRows) {
|
|
69
69
|
if (!matchedRightRows.has(rightRow)) {
|
|
70
|
-
const nullLeft = createNullRow(
|
|
70
|
+
const nullLeft = createNullRow(leftCols ?? [])
|
|
71
71
|
yield mergeRows(nullLeft, rightRow, leftTable, rightTable)
|
|
72
72
|
}
|
|
73
73
|
}
|
|
@@ -104,13 +104,11 @@ export async function* executePositionalJoin(plan, context) {
|
|
|
104
104
|
const maxLen = Math.max(leftRows.length, rightRows.length)
|
|
105
105
|
const leftCols = leftRows[0]?.columns ?? []
|
|
106
106
|
const rightCols = rightRows[0]?.columns ?? []
|
|
107
|
-
const leftPrefixedCols = prefixColumns(leftCols, leftTable)
|
|
108
|
-
const rightPrefixedCols = prefixColumns(rightCols, rightTable)
|
|
109
107
|
|
|
110
108
|
for (let i = 0; i < maxLen; i++) {
|
|
111
109
|
if (signal?.aborted) return
|
|
112
|
-
const leftRow = leftRows[i] ?? createNullRow(
|
|
113
|
-
const rightRow = rightRows[i] ?? createNullRow(
|
|
110
|
+
const leftRow = leftRows[i] ?? createNullRow(leftCols)
|
|
111
|
+
const rightRow = rightRows[i] ?? createNullRow(rightCols)
|
|
114
112
|
yield mergeRows(leftRow, rightRow, leftTable, rightTable)
|
|
115
113
|
}
|
|
116
114
|
}
|
|
@@ -154,10 +152,9 @@ export async function* executeHashJoin(plan, context) {
|
|
|
154
152
|
|
|
155
153
|
// Get column info for NULL row generation
|
|
156
154
|
const rightCols = rightRows.length ? rightRows[0].columns : []
|
|
157
|
-
const rightPrefixedCols = prefixColumns(rightCols, rightTable)
|
|
158
155
|
|
|
159
156
|
/** @type {string[] | undefined} */
|
|
160
|
-
let
|
|
157
|
+
let leftCols
|
|
161
158
|
/** @type {Set<AsyncRow> | undefined} */
|
|
162
159
|
const matchedRightRows = plan.joinType === 'RIGHT' || plan.joinType === 'FULL' ? new Set() : undefined
|
|
163
160
|
|
|
@@ -165,8 +162,8 @@ export async function* executeHashJoin(plan, context) {
|
|
|
165
162
|
for await (const leftRow of executePlan({ plan: plan.left, context })) {
|
|
166
163
|
if (context.signal?.aborted) break
|
|
167
164
|
|
|
168
|
-
if (!
|
|
169
|
-
|
|
165
|
+
if (!leftCols) {
|
|
166
|
+
leftCols = leftRow.columns
|
|
170
167
|
}
|
|
171
168
|
|
|
172
169
|
const keyValue = await evaluateExpr({
|
|
@@ -183,7 +180,7 @@ export async function* executeHashJoin(plan, context) {
|
|
|
183
180
|
yield mergeRows(leftRow, rightRow, leftTable, rightTable)
|
|
184
181
|
}
|
|
185
182
|
} else if (plan.joinType === 'LEFT' || plan.joinType === 'FULL') {
|
|
186
|
-
const nullRight = createNullRow(
|
|
183
|
+
const nullRight = createNullRow(rightCols)
|
|
187
184
|
yield mergeRows(leftRow, nullRight, leftTable, rightTable)
|
|
188
185
|
}
|
|
189
186
|
}
|
|
@@ -192,7 +189,7 @@ export async function* executeHashJoin(plan, context) {
|
|
|
192
189
|
if (matchedRightRows) {
|
|
193
190
|
for (const rightRow of rightRows) {
|
|
194
191
|
if (!matchedRightRows.has(rightRow)) {
|
|
195
|
-
const nullLeft = createNullRow(
|
|
192
|
+
const nullLeft = createNullRow(leftCols ?? [])
|
|
196
193
|
yield mergeRows(nullLeft, rightRow, leftTable, rightTable)
|
|
197
194
|
}
|
|
198
195
|
}
|
|
@@ -244,14 +241,3 @@ function mergeRows(leftRow, rightRow, leftTable, rightTable) {
|
|
|
244
241
|
|
|
245
242
|
return { columns, cells }
|
|
246
243
|
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Prefixes column names with table alias, keeping already-prefixed columns as-is
|
|
250
|
-
*
|
|
251
|
-
* @param {string[]} cols
|
|
252
|
-
* @param {string} table
|
|
253
|
-
* @returns {string[]}
|
|
254
|
-
*/
|
|
255
|
-
function prefixColumns(cols, table) {
|
|
256
|
-
return cols.map(col => col.includes('.') ? col : `${table}.${col}`)
|
|
257
|
-
}
|
package/src/expression/alias.js
CHANGED
|
@@ -10,11 +10,6 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export function derivedAlias(expr) {
|
|
12
12
|
if (expr.type === 'identifier') {
|
|
13
|
-
// For qualified names like 'users.name', use just the column part as alias
|
|
14
|
-
const dotIndex = expr.name.indexOf('.')
|
|
15
|
-
if (dotIndex >= 0) {
|
|
16
|
-
return expr.name.substring(dotIndex + 1)
|
|
17
|
-
}
|
|
18
13
|
return expr.name
|
|
19
14
|
}
|
|
20
15
|
if (expr.type === 'literal') {
|
package/src/expression/binary.js
CHANGED
|
@@ -30,7 +30,7 @@ export function applyBinaryOp(op, a, b) {
|
|
|
30
30
|
if (op === 'AND') return Boolean(a) && Boolean(b)
|
|
31
31
|
if (op === 'OR') return Boolean(a) || Boolean(b)
|
|
32
32
|
if (op === '!=' || op === '<>') return a != b
|
|
33
|
-
if (op === '=') return a == b
|
|
33
|
+
if (op === '=' || op === '==') return a == b
|
|
34
34
|
if (op === '<') return a < b
|
|
35
35
|
if (op === '<=') return a <= b
|
|
36
36
|
if (op === '>') return a > b
|
|
@@ -3,7 +3,7 @@ import { keyify, stringify } from '../execute/utils.js'
|
|
|
3
3
|
import { ArgValueError, ExecutionError } from '../validation/executionErrors.js'
|
|
4
4
|
import { isAggregateFunc, isMathFunc, isRegexpFunc, isSpatialFunc, isStringFunc } from '../validation/functions.js'
|
|
5
5
|
import { UnknownFunctionError } from '../validation/parseErrors.js'
|
|
6
|
-
import { ColumnNotFoundError } from '../validation/
|
|
6
|
+
import { ColumnNotFoundError } from '../validation/tables.js'
|
|
7
7
|
import { derivedAlias } from './alias.js'
|
|
8
8
|
import { applyBinaryOp } from './binary.js'
|
|
9
9
|
import { applyIntervalToDate, dateTrunc, extractField } from './date.js'
|
|
@@ -33,18 +33,21 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
if (node.type === 'identifier') {
|
|
36
|
-
// Try
|
|
37
|
-
if (node.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return row.cells[colName]()
|
|
36
|
+
// Try qualified name first (e.g. 'users.id')
|
|
37
|
+
if (node.prefix) {
|
|
38
|
+
const qualified = node.prefix + '.' + node.name
|
|
39
|
+
if (qualified in row.cells) {
|
|
40
|
+
return row.cells[qualified]()
|
|
41
|
+
}
|
|
42
|
+
// Fall back to just the column part
|
|
43
|
+
if (node.name in row.cells) {
|
|
44
|
+
return row.cells[node.name]()
|
|
46
45
|
}
|
|
47
46
|
} else {
|
|
47
|
+
// Try exact match first
|
|
48
|
+
if (node.name in row.cells) {
|
|
49
|
+
return row.cells[node.name]()
|
|
50
|
+
}
|
|
48
51
|
// For unqualified names, search for a matching prefixed column (e.g. 'id' to 'a.id')
|
|
49
52
|
const suffix = '.' + node.name
|
|
50
53
|
const match = row.columns.find(col => col.endsWith(suffix))
|
|
@@ -54,7 +57,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
54
57
|
}
|
|
55
58
|
// Unknown identifier
|
|
56
59
|
throw new ColumnNotFoundError({
|
|
57
|
-
missingColumn: node.name,
|
|
60
|
+
missingColumn: node.prefix ? node.prefix + '.' + node.name : node.name,
|
|
58
61
|
availableColumns: row.columns,
|
|
59
62
|
rowIndex,
|
|
60
63
|
...node,
|
package/src/expression/math.js
CHANGED
|
@@ -27,7 +27,7 @@ export function evaluateMathFunc({ funcName, args }) {
|
|
|
27
27
|
return Number(dividend) % Number(divisor)
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
if (funcName === 'POWER') {
|
|
30
|
+
if (funcName === 'POWER' || funcName === 'POW') {
|
|
31
31
|
const [base, exponent] = args
|
|
32
32
|
if (base == null || exponent == null) return null
|
|
33
33
|
return Number(base) ** Number(exponent)
|
package/src/parse/parse.js
CHANGED
|
@@ -175,7 +175,7 @@ function parseSelect(state) {
|
|
|
175
175
|
|
|
176
176
|
// Support duckdb-style shorthand "FROM table"
|
|
177
177
|
if (match(state, 'keyword', 'FROM')) {
|
|
178
|
-
columns = [{ type: 'star' }]
|
|
178
|
+
columns = [{ type: 'star', positionStart, positionEnd: positionStart }]
|
|
179
179
|
} else {
|
|
180
180
|
expect(state, 'keyword', 'SELECT')
|
|
181
181
|
distinct = match(state, 'keyword', 'DISTINCT')
|
|
@@ -283,6 +283,9 @@ function parseSelect(state) {
|
|
|
283
283
|
expr,
|
|
284
284
|
direction,
|
|
285
285
|
nulls,
|
|
286
|
+
positionStart,
|
|
287
|
+
positionEnd: state.lastPos,
|
|
288
|
+
|
|
286
289
|
})
|
|
287
290
|
if (!match(state, 'comma')) break
|
|
288
291
|
}
|
|
@@ -339,17 +342,17 @@ function parseSelectList(state) {
|
|
|
339
342
|
const cols = []
|
|
340
343
|
|
|
341
344
|
while (true) {
|
|
342
|
-
const
|
|
345
|
+
const { positionStart, type } = current(state)
|
|
343
346
|
|
|
344
347
|
// Check for qualified asterisk (table.*)
|
|
345
|
-
if (
|
|
348
|
+
if (type === 'identifier') {
|
|
346
349
|
const next = peekToken(state, 1)
|
|
347
350
|
const nextNext = peekToken(state, 2)
|
|
348
351
|
if (next.type === 'dot' && nextNext.type === 'operator' && nextNext.value === '*') {
|
|
349
352
|
const table = consume(state).value
|
|
350
353
|
consume(state) // consume dot
|
|
351
354
|
consume(state) // consume asterisk
|
|
352
|
-
cols.push({ type: 'star', table })
|
|
355
|
+
cols.push({ type: 'star', table, positionStart, positionEnd: state.lastPos })
|
|
353
356
|
if (!match(state, 'comma')) break
|
|
354
357
|
continue
|
|
355
358
|
}
|
|
@@ -357,7 +360,7 @@ function parseSelectList(state) {
|
|
|
357
360
|
|
|
358
361
|
// Check for unqualified asterisk (*)
|
|
359
362
|
if (match(state, 'operator', '*')) {
|
|
360
|
-
cols.push({ type: 'star' })
|
|
363
|
+
cols.push({ type: 'star', positionStart, positionEnd: state.lastPos })
|
|
361
364
|
if (!match(state, 'comma')) break
|
|
362
365
|
continue
|
|
363
366
|
}
|
|
@@ -365,7 +368,7 @@ function parseSelectList(state) {
|
|
|
365
368
|
// Parse derived column with optional alias
|
|
366
369
|
const expr = parseExpression(state)
|
|
367
370
|
const alias = parseAs(state)
|
|
368
|
-
cols.push({ type: 'derived', expr, alias })
|
|
371
|
+
cols.push({ type: 'derived', expr, alias, positionStart, positionEnd: state.lastPos })
|
|
369
372
|
|
|
370
373
|
if (!match(state, 'comma')) break
|
|
371
374
|
}
|
package/src/parse/primary.js
CHANGED
|
@@ -4,13 +4,15 @@ import { RESERVED_KEYWORDS } from '../validation/keywords.js'
|
|
|
4
4
|
import { parseExpression } from './expression.js'
|
|
5
5
|
import { parseFunctionCall } from './functions.js'
|
|
6
6
|
import { parseStatement } from './parse.js'
|
|
7
|
-
import { consume, current, expect, match, peekToken } from './state.js'
|
|
7
|
+
import { consume, current, expect, match, parseError, peekToken } from './state.js'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* @import { ExprNode, IntervalNode, ParserState, WhenClause } from '../types.js'
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
+
* Parse a primary expression, which is the innermost order of operations.
|
|
15
|
+
*
|
|
14
16
|
* @param {ParserState} state
|
|
15
17
|
* @returns {ExprNode}
|
|
16
18
|
*/
|
|
@@ -112,15 +114,19 @@ export function parsePrimary(state) {
|
|
|
112
114
|
|
|
113
115
|
// Table identifier
|
|
114
116
|
let name = consume(state).value
|
|
117
|
+
/** @type {string | undefined} */
|
|
118
|
+
let prefix
|
|
115
119
|
|
|
116
120
|
// table.column
|
|
117
121
|
if (match(state, 'dot')) {
|
|
118
|
-
|
|
122
|
+
prefix = name
|
|
123
|
+
name = expect(state, 'identifier').value
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
return {
|
|
122
127
|
type: 'identifier',
|
|
123
128
|
name,
|
|
129
|
+
prefix,
|
|
124
130
|
positionStart,
|
|
125
131
|
positionEnd: state.lastPos,
|
|
126
132
|
}
|
|
@@ -242,7 +248,7 @@ export function parsePrimary(state) {
|
|
|
242
248
|
}
|
|
243
249
|
}
|
|
244
250
|
|
|
245
|
-
throw
|
|
251
|
+
throw parseError(state, 'expression')
|
|
246
252
|
}
|
|
247
253
|
|
|
248
254
|
/**
|
|
@@ -253,19 +259,20 @@ function parseInterval(state) {
|
|
|
253
259
|
const { positionStart } = expect(state, 'keyword', 'INTERVAL')
|
|
254
260
|
|
|
255
261
|
// Get value (number or quoted string)
|
|
256
|
-
const valueTok =
|
|
262
|
+
const valueTok = current(state)
|
|
257
263
|
/** @type {number} */
|
|
258
264
|
let value
|
|
259
265
|
if (valueTok.type === 'number') {
|
|
260
266
|
value = Number(valueTok.numericValue)
|
|
261
|
-
} else if (valueTok.type === 'string') {
|
|
262
|
-
value =
|
|
263
|
-
if (isNaN(value)) {
|
|
264
|
-
throw new InvalidLiteralError({ expected: 'interval value', ...valueTok })
|
|
265
|
-
}
|
|
267
|
+
} else if (valueTok.type === 'string' && valueTok.value.trim() !== '') {
|
|
268
|
+
value = Number(valueTok.value)
|
|
266
269
|
} else {
|
|
267
|
-
throw
|
|
270
|
+
throw parseError(state, 'interval value (number)')
|
|
271
|
+
}
|
|
272
|
+
if (isNaN(value)) {
|
|
273
|
+
throw new InvalidLiteralError({ expected: 'interval value', ...valueTok })
|
|
268
274
|
}
|
|
275
|
+
consume(state)
|
|
269
276
|
|
|
270
277
|
// Get unit keyword
|
|
271
278
|
const unitTok = consume(state)
|
package/src/parse/state.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SyntaxError } from '../validation/parseErrors.js'
|
|
1
|
+
import { SyntaxError, UnexpectedDotError } from '../validation/parseErrors.js'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @import { ParserState, Token, TokenType } from '../types.js'
|
|
@@ -78,5 +78,12 @@ export function parseError(state, expected) {
|
|
|
78
78
|
const tok = current(state)
|
|
79
79
|
const prevToken = state.tokens[state.pos - 1]
|
|
80
80
|
const after = prevToken?.originalValue ?? prevToken?.value
|
|
81
|
+
if (tok.type === 'dot' && prevToken?.type === 'identifier') {
|
|
82
|
+
const nextToken = state.tokens[state.pos + 1]
|
|
83
|
+
if (nextToken && (nextToken.type === 'identifier' || nextToken.type === 'keyword')) {
|
|
84
|
+
const dottedName = after + '.' + (nextToken.originalValue ?? nextToken.value)
|
|
85
|
+
return new UnexpectedDotError({ dottedName, positionStart: prevToken.positionStart, positionEnd: nextToken.positionEnd })
|
|
86
|
+
}
|
|
87
|
+
}
|
|
81
88
|
return new SyntaxError({ expected, after, ...tok })
|
|
82
89
|
}
|
package/src/parse/tokenize.js
CHANGED
|
@@ -5,7 +5,7 @@ import { InvalidLiteralError, ParseError, UnexpectedCharError } from '../validat
|
|
|
5
5
|
* @import { Token } from '../types.d.ts'
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
const NUMBER_REGEX = /^-?(?:\d+n
|
|
8
|
+
const NUMBER_REGEX = /^-?(?:\d+n|(?:\d+\.?\d*|\d*\.\d+)(?:[eE][+-]?\d+)?)/
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* @param {string} query
|
|
@@ -32,40 +32,42 @@ export function tokenizeSql(query) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
* @param {number} positionStart
|
|
36
35
|
* @returns {Token}
|
|
37
36
|
*/
|
|
38
|
-
function parseNumber(
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
throw new InvalidLiteralError({ expected: 'number', value: query[i] || 'eof', positionStart, positionEnd: i + 1 })
|
|
42
|
-
}
|
|
37
|
+
function parseNumber() {
|
|
38
|
+
const positionStart = i
|
|
39
|
+
let value = query.slice(i).match(NUMBER_REGEX)?.[0] ?? ''
|
|
43
40
|
i += value.length
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
// check for invalid characters immediately following the number
|
|
42
|
+
const ch = peek()
|
|
43
|
+
if (!value || isAlphaNumeric(ch) || ch === '.') {
|
|
44
|
+
const after = query.slice(i).match(/^-?(?:[0-9a-zA-Z_$.]*[0-9][eE][+-]?[0-9])?[0-9a-zA-Z_$.]*/)?.[0] ?? ch
|
|
45
|
+
value += after
|
|
46
|
+
i += after.length
|
|
47
|
+
throw new InvalidLiteralError({ expected: 'number', value, positionStart, positionEnd: i })
|
|
47
48
|
}
|
|
48
49
|
if (value.endsWith('n')) {
|
|
49
50
|
return {
|
|
50
51
|
type: 'number',
|
|
51
52
|
value,
|
|
53
|
+
numericValue: BigInt(value.slice(0, -1)),
|
|
52
54
|
positionStart,
|
|
53
55
|
positionEnd: i,
|
|
54
|
-
numericValue: BigInt(value.slice(0, -1)),
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
return {
|
|
58
59
|
type: 'number',
|
|
59
60
|
value,
|
|
61
|
+
numericValue: Number(value),
|
|
60
62
|
positionStart,
|
|
61
63
|
positionEnd: i,
|
|
62
|
-
numericValue: Number(value),
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
while (i < len) {
|
|
67
68
|
const positionStart = i
|
|
68
|
-
const ch =
|
|
69
|
+
const ch = query[i]
|
|
70
|
+
const next = query[i + 1]
|
|
69
71
|
|
|
70
72
|
if (isWhitespace(ch)) {
|
|
71
73
|
i++
|
|
@@ -73,7 +75,7 @@ export function tokenizeSql(query) {
|
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
// line comment --
|
|
76
|
-
if (ch === '-' &&
|
|
78
|
+
if (ch === '-' && next === '-') {
|
|
77
79
|
while (i < len && query[i] !== '\n') {
|
|
78
80
|
i++
|
|
79
81
|
}
|
|
@@ -81,11 +83,11 @@ export function tokenizeSql(query) {
|
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
// block comment /* ... */
|
|
84
|
-
if (ch === '/' &&
|
|
85
|
-
i +=
|
|
86
|
+
if (ch === '/' && next === '*') {
|
|
87
|
+
i += 3
|
|
86
88
|
while (i < len) {
|
|
87
|
-
if (query[i] === '*' && query[i
|
|
88
|
-
i
|
|
89
|
+
if (query[i - 1] === '*' && query[i] === '/') {
|
|
90
|
+
i++
|
|
89
91
|
break
|
|
90
92
|
}
|
|
91
93
|
i++
|
|
@@ -94,7 +96,7 @@ export function tokenizeSql(query) {
|
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
// negative numbers (when not subtraction)
|
|
97
|
-
if (ch === '-' && isDigit(query[i +
|
|
99
|
+
if (ch === '-' && (isDigit(next) || next === '.' && isDigit(query[i + 2]))) {
|
|
98
100
|
const lastToken = tokens[tokens.length - 1]
|
|
99
101
|
const isValueBefore = lastToken && (
|
|
100
102
|
lastToken.type === 'identifier' ||
|
|
@@ -103,23 +105,23 @@ export function tokenizeSql(query) {
|
|
|
103
105
|
lastToken.type === 'paren' && lastToken.value === ')'
|
|
104
106
|
)
|
|
105
107
|
if (!isValueBefore) {
|
|
106
|
-
tokens.push(parseNumber(
|
|
108
|
+
tokens.push(parseNumber())
|
|
107
109
|
continue
|
|
108
110
|
}
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
// numbers
|
|
112
|
-
if (isDigit(ch)) {
|
|
113
|
-
tokens.push(parseNumber(
|
|
114
|
+
if (isDigit(ch) || ch === '.' && isDigit(next)) {
|
|
115
|
+
tokens.push(parseNumber())
|
|
114
116
|
continue
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
// identifiers / keywords
|
|
118
120
|
if (isAlpha(ch)) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
do {
|
|
122
|
+
i++
|
|
123
|
+
} while (isAlphaNumeric(query[i]))
|
|
124
|
+
const value = query.slice(positionStart, i)
|
|
123
125
|
const upper = value.toUpperCase()
|
|
124
126
|
if (KEYWORDS.has(upper)) {
|
|
125
127
|
tokens.push({
|
|
@@ -173,7 +175,7 @@ export function tokenizeSql(query) {
|
|
|
173
175
|
// operators
|
|
174
176
|
if ('<>!=+-*/%'.includes(ch)) {
|
|
175
177
|
let op = nextChar()
|
|
176
|
-
if ((op === '<' || op === '>' || op === '!') && peek() === '=') {
|
|
178
|
+
if ((op === '<' || op === '>' || op === '!' || op === '=') && peek() === '=') {
|
|
177
179
|
op += nextChar()
|
|
178
180
|
} else if (op === '<' && peek() === '>') {
|
|
179
181
|
op += nextChar()
|
package/src/parse/types.d.ts
CHANGED
|
@@ -23,8 +23,8 @@ export type TokenType =
|
|
|
23
23
|
export interface Token {
|
|
24
24
|
type: TokenType
|
|
25
25
|
value: string
|
|
26
|
+
numericValue?: number | bigint // only for type number
|
|
27
|
+
originalValue?: string // keywords are uppercased, this keeps the original case
|
|
26
28
|
positionStart: number
|
|
27
29
|
positionEnd: number
|
|
28
|
-
numericValue?: number | bigint
|
|
29
|
-
originalValue?: string
|
|
30
30
|
}
|
package/src/plan/columns.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { derivedAlias } from '../expression/alias.js'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* @import { AsyncDataSource, ExprNode, FromSubquery, FromTable, SelectStatement, Statement } from '../types.js'
|
|
4
|
+
* @import { AsyncDataSource, ExprNode, FromSubquery, FromTable, IdentifierNode, SelectStatement, Statement } from '../types.js'
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -18,7 +18,7 @@ export function fromAlias(from) {
|
|
|
18
18
|
*
|
|
19
19
|
* @param {object} options
|
|
20
20
|
* @param {SelectStatement} options.select
|
|
21
|
-
* @param {
|
|
21
|
+
* @param {IdentifierNode[]} [options.parentColumns] - columns needed by the parent query
|
|
22
22
|
* @returns {Map<string, string[] | undefined>}
|
|
23
23
|
*/
|
|
24
24
|
export function extractColumns({ select, parentColumns }) {
|
|
@@ -54,7 +54,8 @@ export function extractColumns({ select, parentColumns }) {
|
|
|
54
54
|
// directly. For non-star queries, parent names may be aliases and are
|
|
55
55
|
// handled below by filtering derived columns and collecting from expressions.
|
|
56
56
|
const hasStar = select.columns.some(col => col.type === 'star' && !col.table)
|
|
57
|
-
|
|
57
|
+
/** @type {IdentifierNode[]} */
|
|
58
|
+
const identifiers = hasStar && parentColumns ? [...parentColumns] : []
|
|
58
59
|
|
|
59
60
|
// Collect ORDER BY identifiers, excluding SELECT aliases (their underlying
|
|
60
61
|
// columns are already collected from select.columns expressions above)
|
|
@@ -69,7 +70,7 @@ export function extractColumns({ select, parentColumns }) {
|
|
|
69
70
|
// When parentColumns is set, skip columns the parent doesn't need
|
|
70
71
|
if (parentColumns) {
|
|
71
72
|
const outputName = col.alias ?? derivedAlias(col.expr)
|
|
72
|
-
if (!parentColumns.
|
|
73
|
+
if (!parentColumns.some(id => id.name === outputName)) continue
|
|
73
74
|
}
|
|
74
75
|
// Exclude earlier SELECT aliases so they aren't treated as source columns
|
|
75
76
|
collectColumnsFromExpr(col.expr, identifiers, selectAliases)
|
|
@@ -92,14 +93,11 @@ export function extractColumns({ select, parentColumns }) {
|
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
// Partition identifiers by table prefix
|
|
95
|
-
for (const name of identifiers) {
|
|
96
|
-
|
|
97
|
-
if (dotIndex >= 0) {
|
|
96
|
+
for (const { prefix, name } of identifiers) {
|
|
97
|
+
if (prefix) {
|
|
98
98
|
// Qualified: add to matching table only
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
const set = perTable.get(tablePrefix)
|
|
102
|
-
if (set) set.add(columnName)
|
|
99
|
+
const set = perTable.get(prefix)
|
|
100
|
+
if (set) set.add(name)
|
|
103
101
|
} else if (aliases.length > 1) {
|
|
104
102
|
// Unqualified in a JOIN: can't disambiguate, request all columns from all tables
|
|
105
103
|
for (const alias of aliases) {
|
|
@@ -122,17 +120,17 @@ export function extractColumns({ select, parentColumns }) {
|
|
|
122
120
|
}
|
|
123
121
|
|
|
124
122
|
/**
|
|
125
|
-
* Recursively collects
|
|
123
|
+
* Recursively collects identifier nodes from an expression
|
|
126
124
|
*
|
|
127
125
|
* @param {ExprNode} expr
|
|
128
|
-
* @param {
|
|
126
|
+
* @param {IdentifierNode[]} columns
|
|
129
127
|
* @param {Set<string>} [aliases] - aliases to exclude from columns
|
|
130
128
|
*/
|
|
131
129
|
function collectColumnsFromExpr(expr, columns, aliases) {
|
|
132
130
|
if (!expr) return
|
|
133
131
|
if (expr.type === 'identifier') {
|
|
134
|
-
if (!aliases?.has(expr.name)) {
|
|
135
|
-
columns.
|
|
132
|
+
if (expr.prefix || !aliases?.has(expr.name)) {
|
|
133
|
+
columns.push(expr)
|
|
136
134
|
}
|
|
137
135
|
} else if (expr.type === 'binary') {
|
|
138
136
|
collectColumnsFromExpr(expr.left, columns, aliases)
|
|
@@ -227,9 +225,9 @@ function inferSelectSourceColumns({ select, cteColumns, tables }) {
|
|
|
227
225
|
result.push(`${fromAlias}.${col}`)
|
|
228
226
|
}
|
|
229
227
|
for (const join of select.joins) {
|
|
230
|
-
const
|
|
228
|
+
const joinAlias = join.alias ?? join.table
|
|
231
229
|
for (const col of lookupTableColumns(join.table, cteColumns, tables)) {
|
|
232
|
-
result.push(`${
|
|
230
|
+
result.push(`${joinAlias}.${col}`)
|
|
233
231
|
}
|
|
234
232
|
}
|
|
235
233
|
return result
|
package/src/plan/plan.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
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 { ColumnNotFoundError, TableNotFoundError } from '../validation/
|
|
4
|
+
import { ColumnNotFoundError, TableNotFoundError } from '../validation/tables.js'
|
|
5
5
|
import { validateScan, validateTableRefs } from '../validation/tables.js'
|
|
6
6
|
import { extractColumns, fromAlias, inferStatementColumns } from './columns.js'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* @import { AsyncDataSource, ExprNode, DerivedColumn, JoinClause, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement, SetOperationStatement, Statement } from '../types.js'
|
|
9
|
+
* @import { AsyncDataSource, ExprNode, DerivedColumn, IdentifierNode, JoinClause, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement, SetOperationStatement, Statement } from '../types.js'
|
|
10
10
|
* @import { QueryPlan } from './types.d.ts'
|
|
11
11
|
*/
|
|
12
12
|
|
|
@@ -31,7 +31,7 @@ export function planSql({ query, functions, tables }) {
|
|
|
31
31
|
* @param {Map<string, QueryPlan>} [options.ctePlans]
|
|
32
32
|
* @param {Map<string, string[]>} [options.cteColumns]
|
|
33
33
|
* @param {Record<string, AsyncDataSource>} [options.tables]
|
|
34
|
-
* @param {
|
|
34
|
+
* @param {IdentifierNode[]} [options.parentColumns] - columns needed by the parent query (for subquery pushdown)
|
|
35
35
|
* @returns {QueryPlan}
|
|
36
36
|
*/
|
|
37
37
|
function planStatement({ stmt, ctePlans, cteColumns, tables, parentColumns }) {
|
|
@@ -99,7 +99,7 @@ function planSetOperation({ compound, ctePlans, cteColumns, tables }) {
|
|
|
99
99
|
* @param {Map<string, QueryPlan>} [options.ctePlans]
|
|
100
100
|
* @param {Map<string, string[]>} [options.cteColumns]
|
|
101
101
|
* @param {Record<string, AsyncDataSource>} [options.tables]
|
|
102
|
-
* @param {
|
|
102
|
+
* @param {IdentifierNode[]} [options.parentColumns] - columns needed by the parent query (for subquery pushdown)
|
|
103
103
|
* @returns {QueryPlan}
|
|
104
104
|
*/
|
|
105
105
|
function planSelect({ select, ctePlans, cteColumns, tables, parentColumns }) {
|
|
@@ -113,7 +113,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns }) {
|
|
|
113
113
|
// Source alias for FROM clause
|
|
114
114
|
const sourceAlias = fromAlias(select.from)
|
|
115
115
|
|
|
116
|
-
//
|
|
116
|
+
// Resolve aliases (and validate qualified references)
|
|
117
117
|
const scopeTables = Object.fromEntries([sourceAlias, ...select.joins.map(j => j.alias ?? j.table)].map(a => [a, true]))
|
|
118
118
|
/** @type {Map<string, ExprNode>} */
|
|
119
119
|
const aliases = new Map()
|
|
@@ -128,11 +128,25 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns }) {
|
|
|
128
128
|
}
|
|
129
129
|
// Validate qualified references
|
|
130
130
|
if (col.table && !(col.table in scopeTables)) {
|
|
131
|
-
|
|
131
|
+
const qualified = col.table + '.*'
|
|
132
|
+
throw new TableNotFoundError({ table: col.table, qualified, tables: scopeTables, ...col })
|
|
132
133
|
}
|
|
133
134
|
return col
|
|
134
135
|
})
|
|
135
136
|
|
|
137
|
+
// Validate qualified references in other clauses
|
|
138
|
+
validateTableRefs(select.where, scopeTables)
|
|
139
|
+
validateTableRefs(select.having, scopeTables)
|
|
140
|
+
for (const expr of select.groupBy) {
|
|
141
|
+
validateTableRefs(expr, scopeTables)
|
|
142
|
+
}
|
|
143
|
+
for (const term of select.orderBy) {
|
|
144
|
+
validateTableRefs(term.expr, scopeTables)
|
|
145
|
+
}
|
|
146
|
+
for (const join of select.joins) {
|
|
147
|
+
validateTableRefs(join.on, scopeTables)
|
|
148
|
+
}
|
|
149
|
+
|
|
136
150
|
// Determine scan hints for direct table scans (WHERE and LIMIT/OFFSET are
|
|
137
151
|
// included so they are only applied to fresh scans, not CTE/subquery plans)
|
|
138
152
|
/** @type {ScanOptions} */
|
|
@@ -216,7 +230,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns }) {
|
|
|
216
230
|
// When parent only needs specific columns, drop unneeded projections
|
|
217
231
|
if (parentColumns) {
|
|
218
232
|
projectColumns = projectColumns.filter(col =>
|
|
219
|
-
col.type === 'star' || parentColumns.
|
|
233
|
+
col.type === 'star' || parentColumns.some(id => id.name === (col.alias ?? derivedAlias(col.expr)))
|
|
220
234
|
)
|
|
221
235
|
}
|
|
222
236
|
plan = { type: 'Project', columns: projectColumns, child: plan }
|
|
@@ -252,7 +266,13 @@ function planFrom({ select, ctePlans, cteColumns, hints, tables }) {
|
|
|
252
266
|
validateScan({ ...select.from, hints, tables })
|
|
253
267
|
return { type: 'Scan', table: select.from.table, hints }
|
|
254
268
|
} else {
|
|
255
|
-
const subPlan = planStatement({
|
|
269
|
+
const subPlan = planStatement({
|
|
270
|
+
stmt: select.from.query,
|
|
271
|
+
ctePlans,
|
|
272
|
+
cteColumns,
|
|
273
|
+
tables,
|
|
274
|
+
parentColumns: hints.columns?.map(name => ({ type: 'identifier', name, positionStart: 0, positionEnd: 0 })),
|
|
275
|
+
})
|
|
256
276
|
// Validate that requested columns exist in subquery output
|
|
257
277
|
const availableColumns = inferStatementColumns({ stmt: select.from.query, cteColumns, tables })
|
|
258
278
|
if (hints.columns && availableColumns.length) {
|
|
@@ -342,7 +362,7 @@ function planJoin({ left, joins, leftTable, ctePlans, cteColumns, perTableColumn
|
|
|
342
362
|
function resolveAliases(node, aliases) {
|
|
343
363
|
if (!node || !aliases.size) return node
|
|
344
364
|
if (node.type === 'identifier') {
|
|
345
|
-
return aliases.get(node.name) ?? node
|
|
365
|
+
return node.prefix ? node : aliases.get(node.name) ?? node
|
|
346
366
|
}
|
|
347
367
|
if (node.type === 'unary') {
|
|
348
368
|
return { ...node, argument: resolveAliases(node.argument, aliases) }
|
|
@@ -394,8 +414,8 @@ function extractSimpleJoinKeys({ condition, leftTable, rightTable }) {
|
|
|
394
414
|
if (left.type !== 'identifier' || right.type !== 'identifier') return
|
|
395
415
|
|
|
396
416
|
// Check if keys are in swapped order (right table ref on left side)
|
|
397
|
-
const leftRefsRight = left.
|
|
398
|
-
const rightRefsLeft = right.
|
|
417
|
+
const leftRefsRight = left.prefix === rightTable
|
|
418
|
+
const rightRefsLeft = right.prefix === leftTable
|
|
399
419
|
|
|
400
420
|
if (leftRefsRight && rightRefsLeft) {
|
|
401
421
|
return { leftKey: right, rightKey: left }
|
package/src/types.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export function isAggregateFunc(name) {
|
|
|
20
20
|
*/
|
|
21
21
|
export function isMathFunc(name) {
|
|
22
22
|
return [
|
|
23
|
-
'FLOOR', 'CEIL', 'CEILING', 'ROUND', 'ABS', 'SIGN', 'MOD', 'EXP', 'LN', 'LOG10', 'POWER', 'SQRT',
|
|
23
|
+
'FLOOR', 'CEIL', 'CEILING', 'ROUND', 'ABS', 'SIGN', 'MOD', 'EXP', 'LN', 'LOG10', 'POW', 'POWER', 'SQRT',
|
|
24
24
|
'SIN', 'COS', 'TAN', 'COT', 'ASIN', 'ACOS', 'ATAN', 'ATAN2', 'DEGREES', 'RADIANS', 'PI',
|
|
25
25
|
'RAND', 'RANDOM',
|
|
26
26
|
].includes(name)
|
|
@@ -87,7 +87,7 @@ export function isStringFunc(name) {
|
|
|
87
87
|
* @returns {op is BinaryOp}
|
|
88
88
|
*/
|
|
89
89
|
export function isBinaryOp(op) {
|
|
90
|
-
return ['AND', 'OR', 'LIKE', '=', '!=', '<>', '<', '>', '<=', '>='].includes(op)
|
|
90
|
+
return ['AND', 'OR', 'LIKE', '=', '==', '!=', '<>', '<', '>', '<=', '>='].includes(op)
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/**
|
|
@@ -135,6 +135,7 @@ export const FUNCTION_SIGNATURES = {
|
|
|
135
135
|
LN: { min: 1, max: 1, signature: 'number' },
|
|
136
136
|
LOG10: { min: 1, max: 1, signature: 'number' },
|
|
137
137
|
POWER: { min: 2, max: 2, signature: 'base, exponent' },
|
|
138
|
+
POW: { min: 2, max: 2, signature: 'base, exponent' },
|
|
138
139
|
SQRT: { min: 1, max: 1, signature: 'number' },
|
|
139
140
|
SIN: { min: 1, max: 1, signature: 'radians' },
|
|
140
141
|
COS: { min: 1, max: 1, signature: 'radians' },
|
|
@@ -37,6 +37,21 @@ export class SyntaxError extends ParseError {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Error when a dot appears after an identifier, suggesting the user meant a dotted name.
|
|
42
|
+
*/
|
|
43
|
+
export class UnexpectedDotError extends ParseError {
|
|
44
|
+
/**
|
|
45
|
+
* @param {Object} options
|
|
46
|
+
* @param {string} options.dottedName - The combined dotted name (e.g., "dataset.parquet")
|
|
47
|
+
* @param {number} options.positionStart
|
|
48
|
+
* @param {number} options.positionEnd
|
|
49
|
+
*/
|
|
50
|
+
constructor({ dottedName, positionStart, positionEnd }) {
|
|
51
|
+
super({ message: `Unexpected "." in "${dottedName}". If this is an identifier, use double quotes: "${dottedName}"`, positionStart, positionEnd })
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
40
55
|
/**
|
|
41
56
|
* Error for invalid literals (numbers, intervals, etc).
|
|
42
57
|
*/
|
|
@@ -48,10 +63,12 @@ export class InvalidLiteralError extends ParseError {
|
|
|
48
63
|
* @param {number} options.positionStart
|
|
49
64
|
* @param {number} options.positionEnd
|
|
50
65
|
* @param {string} [options.validValues] - List of valid values (for enums like interval units)
|
|
66
|
+
* @param {string} [options.after] - What token came before (for context)
|
|
51
67
|
*/
|
|
52
|
-
constructor({ expected, value, positionStart, positionEnd, validValues }) {
|
|
68
|
+
constructor({ expected, value, positionStart, positionEnd, validValues, after }) {
|
|
53
69
|
const suffix = validValues ? `. Valid values: ${validValues}` : ''
|
|
54
|
-
|
|
70
|
+
const afterStr = after ? ` after "${after}"` : ''
|
|
71
|
+
super({ message: `Invalid ${expected} ${value}${afterStr} at position ${positionStart}${suffix}`, positionStart, positionEnd })
|
|
55
72
|
}
|
|
56
73
|
}
|
|
57
74
|
|
package/src/validation/tables.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ExecutionError } from './executionErrors.js'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @import { AsyncDataSource, ExprNode, ScanOptions } from '../types.js'
|
|
@@ -7,15 +7,16 @@ import { ColumnNotFoundError, TableNotFoundError } from './planErrors.js'
|
|
|
7
7
|
/**
|
|
8
8
|
* @param {Object} options
|
|
9
9
|
* @param {string} options.table - The name of the table to validate
|
|
10
|
+
* @param {string} [options.qualified] - The qualified identifier used in the query (for error messages)
|
|
10
11
|
* @param {Record<string, AsyncDataSource>} options.tables - Object mapping table names to data sources
|
|
11
12
|
* @param {number} [options.positionStart] - Optional start position for error reporting
|
|
12
13
|
* @param {number} [options.positionEnd] - Optional end position for error reporting
|
|
13
14
|
* @returns {AsyncDataSource}
|
|
14
15
|
*/
|
|
15
|
-
export function validateTable({ table, tables, positionStart, positionEnd } ) {
|
|
16
|
+
export function validateTable({ table, qualified, tables, positionStart, positionEnd } ) {
|
|
16
17
|
const resolved = tables[table]
|
|
17
18
|
if (!resolved) {
|
|
18
|
-
throw new TableNotFoundError({ table, tables, positionStart, positionEnd })
|
|
19
|
+
throw new TableNotFoundError({ table, qualified, tables, positionStart, positionEnd })
|
|
19
20
|
}
|
|
20
21
|
return resolved
|
|
21
22
|
}
|
|
@@ -52,15 +53,14 @@ export function validateScan({ table, hints, tables, positionStart, positionEnd
|
|
|
52
53
|
*/
|
|
53
54
|
export function validateTableRefs(expr, tables) {
|
|
54
55
|
if (!expr) return
|
|
55
|
-
if (expr.type === 'identifier') {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
return
|
|
56
|
+
if (expr.type === 'identifier' && expr.prefix && !(expr.prefix in tables)) {
|
|
57
|
+
throw new TableNotFoundError({
|
|
58
|
+
table: expr.prefix,
|
|
59
|
+
qualified: expr.prefix + '.' + expr.name,
|
|
60
|
+
tables,
|
|
61
|
+
positionStart: expr.positionStart,
|
|
62
|
+
positionEnd: expr.positionStart + expr.prefix.length,
|
|
63
|
+
})
|
|
64
64
|
}
|
|
65
65
|
if (expr.type === 'binary') {
|
|
66
66
|
validateTableRefs(expr.left, tables)
|
|
@@ -87,3 +87,53 @@ export function validateTableRefs(expr, tables) {
|
|
|
87
87
|
validateTableRefs(expr.elseResult, tables)
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Error for missing table references.
|
|
93
|
+
*/
|
|
94
|
+
export class TableNotFoundError extends ExecutionError {
|
|
95
|
+
/**
|
|
96
|
+
* @param {Object} options
|
|
97
|
+
* @param {string} options.table - The missing table name
|
|
98
|
+
* @param {string} [options.qualified] - The identifier used in the query
|
|
99
|
+
* @param {Record<string, any>} options.tables - Available tables object
|
|
100
|
+
* @param {number} [options.positionStart]
|
|
101
|
+
* @param {number} [options.positionEnd]
|
|
102
|
+
*/
|
|
103
|
+
constructor({ table, qualified, tables, positionStart, positionEnd }) {
|
|
104
|
+
const usage = qualified ? ` in "${qualified}"` : ''
|
|
105
|
+
const available = tables
|
|
106
|
+
? `. Available tables: ${Object.keys(tables).join(', ')}`
|
|
107
|
+
: ''
|
|
108
|
+
super({
|
|
109
|
+
message: `Table "${table}" not found${usage}${available}`,
|
|
110
|
+
positionStart,
|
|
111
|
+
positionEnd,
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Error for missing column references.
|
|
118
|
+
*/
|
|
119
|
+
export class ColumnNotFoundError extends ExecutionError {
|
|
120
|
+
/**
|
|
121
|
+
* @param {Object} options
|
|
122
|
+
* @param {string} options.missingColumn - The missing column name
|
|
123
|
+
* @param {string[]} options.availableColumns - List of available column names
|
|
124
|
+
* @param {number} options.positionStart
|
|
125
|
+
* @param {number} options.positionEnd
|
|
126
|
+
* @param {number} [options.rowIndex] - 1-based row number where error occurred
|
|
127
|
+
*/
|
|
128
|
+
constructor({ missingColumn, availableColumns, positionStart, positionEnd, rowIndex }) {
|
|
129
|
+
const available = availableColumns.length > 0
|
|
130
|
+
? `. Available columns: ${availableColumns.join(', ')}`
|
|
131
|
+
: ''
|
|
132
|
+
super({
|
|
133
|
+
message: `Column "${missingColumn}" not found${available}`,
|
|
134
|
+
positionStart,
|
|
135
|
+
positionEnd,
|
|
136
|
+
rowIndex,
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { ExecutionError } from './executionErrors.js'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Error for missing table references.
|
|
5
|
-
*/
|
|
6
|
-
export class TableNotFoundError extends ExecutionError {
|
|
7
|
-
/**
|
|
8
|
-
* @param {Object} options
|
|
9
|
-
* @param {string} options.table - The missing table name
|
|
10
|
-
* @param {Record<string, any>} options.tables - Available tables object
|
|
11
|
-
* @param {number} [options.positionStart]
|
|
12
|
-
* @param {number} [options.positionEnd]
|
|
13
|
-
*/
|
|
14
|
-
constructor({ table, tables, positionStart, positionEnd }) {
|
|
15
|
-
const names = tables ? Object.keys(tables) : []
|
|
16
|
-
const available = names.length
|
|
17
|
-
? `. Available tables: ${names.join(', ')}`
|
|
18
|
-
: ''
|
|
19
|
-
super({
|
|
20
|
-
message: `Table "${table}" not found${available}`,
|
|
21
|
-
positionStart,
|
|
22
|
-
positionEnd,
|
|
23
|
-
})
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Error for missing column references.
|
|
29
|
-
*/
|
|
30
|
-
export class ColumnNotFoundError extends ExecutionError {
|
|
31
|
-
/**
|
|
32
|
-
* @param {Object} options
|
|
33
|
-
* @param {string} options.missingColumn - The missing column name
|
|
34
|
-
* @param {string[]} options.availableColumns - List of available column names
|
|
35
|
-
* @param {number} options.positionStart
|
|
36
|
-
* @param {number} options.positionEnd
|
|
37
|
-
* @param {number} [options.rowIndex] - 1-based row number where error occurred
|
|
38
|
-
*/
|
|
39
|
-
constructor({ missingColumn, availableColumns, positionStart, positionEnd, rowIndex }) {
|
|
40
|
-
const available = availableColumns.length > 0
|
|
41
|
-
? `. Available columns: ${availableColumns.join(', ')}`
|
|
42
|
-
: ''
|
|
43
|
-
super({
|
|
44
|
-
message: `Column "${missingColumn}" not found${available}`,
|
|
45
|
-
positionStart,
|
|
46
|
-
positionEnd,
|
|
47
|
-
rowIndex,
|
|
48
|
-
})
|
|
49
|
-
}
|
|
50
|
-
}
|