squirreling 0.11.4 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.11.4",
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.8.1",
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'
@@ -167,12 +167,12 @@ export type ExprNode =
167
167
  | IntervalNode
168
168
  | StarNode
169
169
 
170
- export interface StarColumn {
170
+ export interface StarColumn extends AstBase {
171
171
  type: 'star'
172
172
  table?: string
173
173
  }
174
174
 
175
- export interface DerivedColumn {
175
+ export interface DerivedColumn extends AstBase {
176
176
  type: 'derived'
177
177
  expr: ExprNode
178
178
  alias?: string
@@ -180,7 +180,7 @@ export interface DerivedColumn {
180
180
 
181
181
  export type SelectColumn = StarColumn | DerivedColumn
182
182
 
183
- export interface OrderByItem {
183
+ export interface OrderByItem extends AstBase {
184
184
  expr: ExprNode
185
185
  direction: 'ASC' | 'DESC'
186
186
  nulls?: 'FIRST' | 'LAST'
@@ -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/planErrors.js'
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'
@@ -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)
@@ -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 tok = current(state)
345
+ const { positionStart, type } = current(state)
343
346
 
344
347
  // Check for qualified asterisk (table.*)
345
- if (tok.type === 'identifier') {
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
  }
@@ -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
  */
@@ -246,7 +248,7 @@ export function parsePrimary(state) {
246
248
  }
247
249
  }
248
250
 
249
- throw new SyntaxError({ expected: 'expression', ...tok })
251
+ throw parseError(state, 'expression')
250
252
  }
251
253
 
252
254
  /**
@@ -257,19 +259,20 @@ function parseInterval(state) {
257
259
  const { positionStart } = expect(state, 'keyword', 'INTERVAL')
258
260
 
259
261
  // Get value (number or quoted string)
260
- const valueTok = consume(state)
262
+ const valueTok = current(state)
261
263
  /** @type {number} */
262
264
  let value
263
265
  if (valueTok.type === 'number') {
264
266
  value = Number(valueTok.numericValue)
265
- } else if (valueTok.type === 'string') {
266
- value = parseFloat(valueTok.value)
267
- if (isNaN(value)) {
268
- throw new InvalidLiteralError({ expected: 'interval value', ...valueTok })
269
- }
267
+ } else if (valueTok.type === 'string' && valueTok.value.trim() !== '') {
268
+ value = Number(valueTok.value)
270
269
  } else {
271
- throw new SyntaxError({ expected: 'interval value (number)', ...valueTok })
270
+ throw parseError(state, 'interval value (number)')
271
+ }
272
+ if (isNaN(value)) {
273
+ throw new InvalidLiteralError({ expected: 'interval value', ...valueTok })
272
274
  }
275
+ consume(state)
273
276
 
274
277
  // Get unit keyword
275
278
  const unitTok = consume(state)
@@ -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
  }
@@ -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|\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/
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(positionStart) {
39
- const value = query.slice(i).match(NUMBER_REGEX)?.[0]
40
- if (!value) {
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
- const next = peek()
45
- if (isAlpha(next) || next === '.') {
46
- throw new InvalidLiteralError({ expected: 'number', value: value + next, positionStart, positionEnd: i + 1 })
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 = peek()
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 === '-' && query[i + 1] === '-') {
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 === '/' && query[i + 1] === '*') {
85
- i += 2
86
+ if (ch === '/' && next === '*') {
87
+ i += 3
86
88
  while (i < len) {
87
- if (query[i] === '*' && query[i + 1] === '/') {
88
- i += 2
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 + 1])) {
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(positionStart))
108
+ tokens.push(parseNumber())
107
109
  continue
108
110
  }
109
111
  }
110
112
 
111
113
  // numbers
112
- if (isDigit(ch)) {
113
- tokens.push(parseNumber(positionStart))
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
- let value = ''
120
- while (isAlphaNumeric(peek())) {
121
- value += nextChar()
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()
@@ -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
  }
@@ -225,9 +225,9 @@ function inferSelectSourceColumns({ select, cteColumns, tables }) {
225
225
  result.push(`${fromAlias}.${col}`)
226
226
  }
227
227
  for (const join of select.joins) {
228
- const alias = join.alias ?? join.table
228
+ const joinAlias = join.alias ?? join.table
229
229
  for (const col of lookupTableColumns(join.table, cteColumns, tables)) {
230
- result.push(`${alias}.${col}`)
230
+ result.push(`${joinAlias}.${col}`)
231
231
  }
232
232
  }
233
233
  return result
package/src/plan/plan.js CHANGED
@@ -1,7 +1,7 @@
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/planErrors.js'
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
 
@@ -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
- // Validate qualified references and resolve aliases
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
- throw new TableNotFoundError({ table: col.table, tables: scopeTables })
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} */
package/src/types.d.ts CHANGED
@@ -115,6 +115,7 @@ export type MathFunc =
115
115
  | 'EXP'
116
116
  | 'LN'
117
117
  | 'LOG10'
118
+ | 'POW'
118
119
  | 'POWER'
119
120
  | 'SQRT'
120
121
  | 'SIN'
@@ -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
- super({ message: `Invalid ${expected} ${value} at position ${positionStart}${suffix}`, positionStart, positionEnd })
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
 
@@ -1,4 +1,4 @@
1
- import { ColumnNotFoundError, TableNotFoundError } from './planErrors.js'
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,13 +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
- if (expr.prefix) {
57
- if (!(expr.prefix in tables)) {
58
- throw new TableNotFoundError({ table: expr.prefix, tables, positionStart: expr.positionStart, positionEnd: expr.positionStart + expr.prefix.length })
59
- }
60
- }
61
- 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
+ })
62
64
  }
63
65
  if (expr.type === 'binary') {
64
66
  validateTableRefs(expr.left, tables)
@@ -85,3 +87,53 @@ export function validateTableRefs(expr, tables) {
85
87
  validateTableRefs(expr.elseResult, tables)
86
88
  }
87
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
- }