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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.11.3",
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'
@@ -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
  }
@@ -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
  }
@@ -26,18 +26,18 @@ export async function* executeNestedLoopJoin(plan, context) {
26
26
  rightRows.push(row)
27
27
  }
28
28
 
29
- const rightPrefixedCols = rightRows.length ? prefixColumns(rightRows[0].columns, rightTable) : []
29
+ const rightCols = rightRows.length ? rightRows[0].columns : []
30
30
 
31
31
  /** @type {string[] | undefined} */
32
- let leftPrefixedCols = undefined
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 (!leftPrefixedCols) {
40
- leftPrefixedCols = prefixColumns(leftRow.columns, leftTable)
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(rightPrefixedCols)
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(leftPrefixedCols ?? [])
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(leftPrefixedCols)
113
- const rightRow = rightRows[i] ?? createNullRow(rightPrefixedCols)
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 leftPrefixedCols
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 (!leftPrefixedCols) {
169
- leftPrefixedCols = prefixColumns(leftRow.columns, leftTable)
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(rightPrefixedCols)
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(leftPrefixedCols ?? [])
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
- }
@@ -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') {
@@ -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'
@@ -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 exact match first (handles both qualified and unqualified names)
37
- if (node.name in row.cells) {
38
- return row.cells[node.name]()
39
- }
40
- const dotIndex = node.name.indexOf('.')
41
- if (dotIndex >= 0) {
42
- // For qualified names like 'users.id', try just the column part
43
- const colName = node.name.substring(dotIndex + 1)
44
- if (colName in row.cells) {
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,
@@ -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
  */
@@ -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
- name += '.' + expect(state, 'identifier').value
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 new SyntaxError({ expected: 'expression', ...tok })
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 = consume(state)
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 = parseFloat(valueTok.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 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 })
268
274
  }
275
+ consume(state)
269
276
 
270
277
  // Get unit keyword
271
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
  }
@@ -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 {string[]} [options.parentColumns] - columns needed by the parent query
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
- const identifiers = new Set(hasStar ? parentColumns : undefined)
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.includes(outputName)) continue
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
- const dotIndex = name.indexOf('.')
97
- if (dotIndex >= 0) {
96
+ for (const { prefix, name } of identifiers) {
97
+ if (prefix) {
98
98
  // Qualified: add to matching table only
99
- const tablePrefix = name.substring(0, dotIndex)
100
- const columnName = name.substring(dotIndex + 1)
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 column names (identifiers) from an expression
123
+ * Recursively collects identifier nodes from an expression
126
124
  *
127
125
  * @param {ExprNode} expr
128
- * @param {Set<string>} columns
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.add(expr.name)
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 alias = join.alias ?? join.table
228
+ const joinAlias = join.alias ?? join.table
231
229
  for (const col of lookupTableColumns(join.table, cteColumns, tables)) {
232
- result.push(`${alias}.${col}`)
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/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
 
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 {string[]} [options.parentColumns] - columns needed by the parent query (for subquery pushdown)
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 {string[]} [options.parentColumns] - columns needed by the parent query (for subquery pushdown)
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
- // 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} */
@@ -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.includes(col.alias ?? derivedAlias(col.expr))
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({ stmt: select.from.query, ctePlans, cteColumns, tables, parentColumns: hints.columns })
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.name.startsWith(`${rightTable}.`)
398
- const rightRefsLeft = right.name.startsWith(`${leftTable}.`)
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
@@ -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,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
- const dotIndex = expr.name.indexOf('.')
57
- if (dotIndex >= 0) {
58
- const table = expr.name.substring(0, dotIndex)
59
- if (!(table in tables)) {
60
- throw new TableNotFoundError({ table, tables, positionStart: expr.positionStart, positionEnd: expr.positionStart + dotIndex })
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
- }