squirreling 0.10.0 → 0.10.2

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.
@@ -1,21 +1,20 @@
1
- import { argValueError } from '../validationErrors.js'
1
+ import { argValueError } from '../validation/expressionErrors.js'
2
2
 
3
3
  /**
4
- * @import { SqlPrimitive } from '../types.js'
4
+ * @import { FunctionNode, RegExpFunction, SqlPrimitive } from '../types.js'
5
5
  */
6
6
 
7
7
  /**
8
8
  * Evaluate a regexp function
9
9
  *
10
10
  * @param {Object} options
11
- * @param {string} options.funcName - Uppercase function name
11
+ * @param {RegExpFunction} options.funcName
12
+ * @param {FunctionNode} options.node
12
13
  * @param {SqlPrimitive[]} options.args - Function arguments
13
- * @param {number} options.positionStart - Start position in SQL string for error reporting
14
- * @param {number} options.positionEnd - End position in SQL string for error reporting
15
- * @param {number} [options.rowIndex] - Row number for error reporting
14
+ * @param {number} options.rowIndex - Row index for error reporting
16
15
  * @returns {SqlPrimitive}
17
16
  */
18
- export function evaluateRegexpFunc({ funcName, args, positionStart, positionEnd, rowIndex }) {
17
+ export function evaluateRegexpFunc({ funcName, node, args, rowIndex }) {
19
18
  if (funcName === 'REGEXP_SUBSTR') {
20
19
  const str = args[0]
21
20
  const pattern = args[1]
@@ -29,10 +28,8 @@ export function evaluateRegexpFunc({ funcName, args, positionStart, positionEnd,
29
28
  position = Number(args[2])
30
29
  if (!Number.isInteger(position) || position < 1) {
31
30
  throw argValueError({
32
- funcName,
31
+ ...node,
33
32
  message: `position must be a positive integer, got ${args[2]}`,
34
- positionStart,
35
- positionEnd,
36
33
  hint: 'SQL uses 1-based indexing.',
37
34
  rowIndex,
38
35
  })
@@ -45,10 +42,9 @@ export function evaluateRegexpFunc({ funcName, args, positionStart, positionEnd,
45
42
  occurrence = Number(args[3])
46
43
  if (!Number.isInteger(occurrence) || occurrence < 1) {
47
44
  throw argValueError({
48
- funcName,
45
+ ...node,
49
46
  message: `occurrence must be a positive integer, got ${args[3]}`,
50
- positionStart,
51
- positionEnd,
47
+ hint: 'SQL uses 1-based indexing.',
52
48
  rowIndex,
53
49
  })
54
50
  }
@@ -60,10 +56,8 @@ export function evaluateRegexpFunc({ funcName, args, positionStart, positionEnd,
60
56
  regex = new RegExp(patternStr, 'g')
61
57
  } catch (/** @type {any} */ error) {
62
58
  throw argValueError({
63
- funcName,
59
+ ...node,
64
60
  message: `invalid regex pattern: ${error.message}`,
65
- positionStart,
66
- positionEnd,
67
61
  rowIndex,
68
62
  })
69
63
  }
@@ -99,10 +93,8 @@ export function evaluateRegexpFunc({ funcName, args, positionStart, positionEnd,
99
93
  position = Number(args[3])
100
94
  if (!Number.isInteger(position) || position < 1) {
101
95
  throw argValueError({
102
- funcName,
96
+ ...node,
103
97
  message: `position must be a positive integer, got ${args[3]}`,
104
- positionStart,
105
- positionEnd,
106
98
  hint: 'SQL uses 1-based indexing.',
107
99
  rowIndex,
108
100
  })
@@ -115,10 +107,8 @@ export function evaluateRegexpFunc({ funcName, args, positionStart, positionEnd,
115
107
  occurrence = Number(args[4])
116
108
  if (!Number.isInteger(occurrence) || occurrence < 0) {
117
109
  throw argValueError({
118
- funcName,
110
+ ...node,
119
111
  message: `occurrence must be a non-negative integer, got ${args[4]}`,
120
- positionStart,
121
- positionEnd,
122
112
  hint: 'Use 0 to replace all occurrences.',
123
113
  rowIndex,
124
114
  })
@@ -131,10 +121,8 @@ export function evaluateRegexpFunc({ funcName, args, positionStart, positionEnd,
131
121
  regex = new RegExp(patternStr, 'g')
132
122
  } catch (/** @type {any} */ error) {
133
123
  throw argValueError({
134
- funcName,
124
+ ...node,
135
125
  message: `invalid regex pattern: ${error.message}`,
136
- positionStart,
137
- positionEnd,
138
126
  rowIndex,
139
127
  })
140
128
  }
@@ -1,30 +1,27 @@
1
1
  /**
2
- * @import { SqlPrimitive, StringFunc } from '../types.js'
2
+ * @import { FunctionNode, SqlPrimitive, StringFunc } from '../types.js'
3
3
  */
4
4
 
5
- import { argValueError } from '../validationErrors.js'
5
+ import { argValueError } from '../validation/expressionErrors.js'
6
6
 
7
7
  /**
8
8
  * Evaluate a string function
9
9
  *
10
10
  * @param {Object} options
11
- * @param {StringFunc} options.funcName - Uppercase function name
11
+ * @param {StringFunc} options.funcName
12
+ * @param {FunctionNode} options.node
12
13
  * @param {SqlPrimitive[]} options.args - Function arguments
13
- * @param {number} options.positionStart - Start position for error reporting
14
- * @param {number} options.positionEnd - End position for error reporting
15
- * @param {number} [options.rowIndex] - Row index for error reporting
14
+ * @param {number} options.rowIndex - Row index for error reporting
16
15
  * @returns {SqlPrimitive}
17
16
  */
18
- export function evaluateStringFunc({ funcName, args, positionStart, positionEnd, rowIndex }) {
17
+ export function evaluateStringFunc({ funcName, node, args, rowIndex }) {
19
18
  if (funcName === 'CONCAT') {
20
19
  // Returns NULL if any argument is NULL
21
20
  if (args.some(a => a == null)) return null
22
21
  if (args.some(a => typeof a === 'object')) {
23
22
  throw argValueError({
24
- funcName: 'CONCAT',
23
+ ...node,
25
24
  message: 'does not support object arguments',
26
- positionStart,
27
- positionEnd,
28
25
  hint: 'Use CAST to convert objects to strings first.',
29
26
  rowIndex,
30
27
  })
@@ -53,10 +50,8 @@ export function evaluateStringFunc({ funcName, args, positionStart, positionEnd,
53
50
  const start = Number(args[1])
54
51
  if (!Number.isInteger(start) || start < 1) {
55
52
  throw argValueError({
56
- funcName,
53
+ ...node,
57
54
  message: `start position must be a positive integer, got ${args[1]}`,
58
- positionStart,
59
- positionEnd,
60
55
  hint: 'SQL uses 1-based indexing.',
61
56
  rowIndex,
62
57
  })
@@ -67,10 +62,9 @@ export function evaluateStringFunc({ funcName, args, positionStart, positionEnd,
67
62
  const len = Number(args[2])
68
63
  if (!Number.isInteger(len) || len < 0) {
69
64
  throw argValueError({
70
- funcName,
65
+ ...node,
71
66
  message: `length must be a non-negative integer, got ${args[2]}`,
72
- positionStart,
73
- positionEnd,
67
+ hint: 'SQL uses 1-based indexing.',
74
68
  rowIndex,
75
69
  })
76
70
  }
@@ -97,10 +91,9 @@ export function evaluateStringFunc({ funcName, args, positionStart, positionEnd,
97
91
  const len = Number(n)
98
92
  if (!Number.isInteger(len) || len < 0) {
99
93
  throw argValueError({
100
- funcName,
94
+ ...node,
101
95
  message: `length must be a non-negative integer, got ${n}`,
102
- positionStart,
103
- positionEnd,
96
+ hint: 'SQL uses 1-based indexing.',
104
97
  rowIndex,
105
98
  })
106
99
  }
@@ -113,10 +106,9 @@ export function evaluateStringFunc({ funcName, args, positionStart, positionEnd,
113
106
  const len = Number(n)
114
107
  if (!Number.isInteger(len) || len < 0) {
115
108
  throw argValueError({
116
- funcName,
109
+ ...node,
117
110
  message: `length must be a non-negative integer, got ${n}`,
118
- positionStart,
119
- positionEnd,
111
+ hint: 'SQL uses 1-based indexing.',
120
112
  rowIndex,
121
113
  })
122
114
  }
package/src/index.d.ts CHANGED
@@ -55,6 +55,7 @@ export function parseSql(options: ParseSqlOptions): SelectStatement
55
55
  * @param options
56
56
  * @param options.query - SQL query string or parsed SelectStatement
57
57
  * @param options.functions - user-defined functions available in the SQL context
58
+ * @param options.tables - optional table metadata for planning
58
59
  * @returns the root of the query plan tree
59
60
  */
60
61
  export function planSql(options: PlanSqlOptions): QueryPlan
@@ -1,5 +1,5 @@
1
- import { syntaxError } from '../parseErrors.js'
2
- import { isBinaryOp } from '../validation.js'
1
+ import { isBinaryOp } from '../validation/functions.js'
2
+ import { syntaxError } from '../validation/parseErrors.js'
3
3
  import { parseAdditive, parseExpression, parseSubquery } from './expression.js'
4
4
  import { consume, current, expect, match, peekToken } from './state.js'
5
5
 
@@ -3,8 +3,8 @@ import {
3
3
  missingClauseError,
4
4
  syntaxError,
5
5
  unknownFunctionError,
6
- } from '../parseErrors.js'
7
- import { RESERVED_KEYWORDS, isExtractField, isIntervalUnit, isKnownFunction } from '../validation.js'
6
+ } from '../validation/parseErrors.js'
7
+ import { RESERVED_KEYWORDS, isCastType, isExtractField, isIntervalUnit, isKnownFunction } from '../validation/functions.js'
8
8
  import { parseComparison } from './comparison.js'
9
9
  import { parseFunctionCall } from './functions.js'
10
10
  import { parseSelectInternal } from './parse.js'
@@ -60,11 +60,19 @@ export function parsePrimary(state) {
60
60
  const expr = parseExpression(state)
61
61
  expect(state, 'keyword', 'AS')
62
62
  const typeTok = expectIdentifier(state)
63
+ const toType = typeTok.value.toUpperCase()
64
+ if (!isCastType(toType)) {
65
+ throw syntaxError({
66
+ ...typeTok,
67
+ expected: 'cast type (STRING, INT, BIGINT, FLOAT, BOOL)',
68
+ received: `"${typeTok.value}"`,
69
+ })
70
+ }
63
71
  expect(state, 'paren', ')')
64
72
  return {
65
73
  type: 'cast',
66
74
  expr,
67
- toType: typeTok.value,
75
+ toType,
68
76
  positionStart,
69
77
  positionEnd: state.lastPos,
70
78
  }
@@ -75,13 +83,11 @@ export function parsePrimary(state) {
75
83
  consume(state) // EXTRACT
76
84
  consume(state) // '('
77
85
  const fieldTok = current(state)
78
- const isValidType = fieldTok.type === 'keyword' || fieldTok.type === 'identifier'
79
- if (!isValidType || !isExtractField(fieldTok.value)) {
86
+ if (!isExtractField(fieldTok.value)) {
80
87
  throw syntaxError({
88
+ ...fieldTok,
81
89
  expected: 'extract field (YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, DOW, EPOCH)',
82
90
  received: `"${fieldTok.value}"`,
83
- positionStart: fieldTok.positionStart,
84
- positionEnd: fieldTok.positionEnd,
85
91
  })
86
92
  }
87
93
  consume(state) // field
@@ -90,7 +96,7 @@ export function parsePrimary(state) {
90
96
  expect(state, 'paren', ')')
91
97
  return {
92
98
  type: 'function',
93
- name: 'EXTRACT',
99
+ funcName: 'EXTRACT',
94
100
  args: [
95
101
  { type: 'literal', value: fieldTok.value, positionStart: fieldTok.positionStart, positionEnd: fieldTok.positionEnd },
96
102
  expr,
@@ -123,7 +129,7 @@ export function parsePrimary(state) {
123
129
  consume(state)
124
130
  return {
125
131
  type: 'function',
126
- name: tok.value,
132
+ funcName: tok.value,
127
133
  args: [],
128
134
  positionStart,
129
135
  positionEnd: state.lastPos,
@@ -217,7 +223,12 @@ export function parsePrimary(state) {
217
223
  const condition = parseExpression(state)
218
224
  expect(state, 'keyword', 'THEN')
219
225
  const result = parseExpression(state)
220
- whenClauses.push({ condition, result })
226
+ whenClauses.push({
227
+ condition,
228
+ result,
229
+ positionStart: condition.positionStart,
230
+ positionEnd: result.positionEnd,
231
+ })
221
232
  }
222
233
 
223
234
  if (whenClauses.length === 0) {
@@ -1,5 +1,5 @@
1
- import { ParseError, argCountParseError, syntaxError } from '../parseErrors.js'
2
- import { isAggregateFunc, validateFunctionArgCount } from '../validation.js'
1
+ import { isAggregateFunc, validateFunctionArgCount } from '../validation/functions.js'
2
+ import { ParseError, syntaxError } from '../validation/parseErrors.js'
3
3
  import { parseExpression } from './expression.js'
4
4
  import { consume, current, expect, match } from './state.js'
5
5
 
@@ -17,11 +17,13 @@ import { consume, current, expect, match } from './state.js'
17
17
  * @returns {ExprNode}
18
18
  */
19
19
  export function parseFunctionCall(state, funcName, positionStart) {
20
- consume(state) // '('
20
+ const funcNameUpper = funcName.toUpperCase()
21
+ consume(state) // '(' checked by caller
21
22
 
22
23
  /** @type {ExprNode[]} */
23
24
  const args = []
24
- let distinct = false
25
+ /** @type {true | undefined} */
26
+ let distinct
25
27
 
26
28
  // Check for DISTINCT or ALL keyword (for aggregate functions like COUNT(DISTINCT x))
27
29
  if (current(state).type === 'keyword' && current(state).value === 'DISTINCT') {
@@ -48,14 +50,12 @@ export function parseFunctionCall(state, funcName, positionStart) {
48
50
  if (!match(state, 'comma')) break
49
51
  }
50
52
  }
51
-
52
53
  expect(state, 'paren', ')')
53
54
 
54
55
  // Check for FILTER clause (only valid for aggregate functions)
55
56
  /** @type {ExprNode | undefined} */
56
57
  let filter
57
58
  if (current(state).type === 'keyword' && current(state).value === 'FILTER') {
58
- const funcNameUpper = funcName.toUpperCase()
59
59
  if (!isAggregateFunc(funcNameUpper)) {
60
60
  throw syntaxError({
61
61
  expected: 'aggregate function for FILTER clause',
@@ -70,44 +70,35 @@ export function parseFunctionCall(state, funcName, positionStart) {
70
70
  filter = parseExpression(state)
71
71
  expect(state, 'paren', ')')
72
72
  }
73
+ const positionEnd = state.lastPos
73
74
 
74
75
  // Validate star argument at parse time (only COUNT supports *)
75
- const funcNameUpper = funcName.toUpperCase()
76
76
  const hasStar = args.length === 1 && args[0].type === 'star'
77
- if (hasStar && isAggregateFunc(funcNameUpper) && funcNameUpper !== 'COUNT') {
77
+ if (hasStar && funcNameUpper !== 'COUNT') {
78
78
  throw new ParseError({
79
79
  message: `${funcName} cannot be applied to "*"`,
80
80
  positionStart,
81
- positionEnd: state.lastPos,
81
+ positionEnd,
82
82
  })
83
83
  }
84
84
  if (hasStar && distinct) {
85
85
  throw new ParseError({
86
86
  message: 'COUNT(DISTINCT *) is not allowed',
87
87
  positionStart,
88
- positionEnd: state.lastPos,
88
+ positionEnd,
89
89
  })
90
90
  }
91
91
 
92
92
  // Validate argument count at parse time
93
- const validation = validateFunctionArgCount(funcNameUpper, args.length, state.functions)
94
- if (!validation.valid) {
95
- throw argCountParseError({
96
- funcName,
97
- expected: validation.expected,
98
- received: args.length,
99
- positionStart,
100
- positionEnd: state.lastPos,
101
- })
102
- }
93
+ validateFunctionArgCount(funcNameUpper, args.length, positionStart, positionEnd, state.functions)
103
94
 
104
95
  return {
105
96
  type: 'function',
106
- name: funcName,
97
+ funcName,
107
98
  args,
108
- distinct: distinct || undefined,
99
+ distinct,
109
100
  filter,
110
101
  positionStart,
111
- positionEnd: state.lastPos,
102
+ positionEnd,
112
103
  }
113
104
  }
@@ -1,7 +1,7 @@
1
+ import { expectNoAggregate } from '../validation/aggregates.js'
1
2
  import { parseExpression } from './expression.js'
2
3
  import { parseTableAlias } from './parse.js'
3
4
  import { consume, current, expect, expectIdentifier, match } from './state.js'
4
- import { expectNoAggregate } from '../validation.js'
5
5
 
6
6
  /**
7
7
  * @import { ExprNode, JoinClause, JoinType, ParserState } from '../types.js'
@@ -59,7 +59,8 @@ export function parseJoins(state) {
59
59
  }
60
60
 
61
61
  // Parse table name and optional alias
62
- const tableName = expectIdentifier(state).value
62
+ const tableTok = expectIdentifier(state)
63
+ const tableName = tableTok.value
63
64
  const tableAlias = parseTableAlias(state)
64
65
 
65
66
  // Parse ON condition (not for POSITIONAL joins)
@@ -76,6 +77,8 @@ export function parseJoins(state) {
76
77
  table: tableName,
77
78
  alias: tableAlias,
78
79
  on: condition,
80
+ positionStart: tableTok.positionStart,
81
+ positionEnd: tableTok.positionEnd,
79
82
  })
80
83
  }
81
84
 
@@ -1,9 +1,10 @@
1
+ import { expectNoAggregate, findAggregate } from '../validation/aggregates.js'
2
+ import { RESERVED_AFTER_COLUMN, RESERVED_AFTER_TABLE } from '../validation/functions.js'
3
+ import { duplicateCTEError } from '../validation/parseErrors.js'
1
4
  import { parseExpression } from './expression.js'
2
5
  import { parseJoins } from './joins.js'
3
6
  import { consume, current, expect, expectIdentifier, match, parseError, peekToken } from './state.js'
4
7
  import { tokenizeSql } from './tokenize.js'
5
- import { duplicateCTEError } from '../parseErrors.js'
6
- import { RESERVED_AFTER_COLUMN, RESERVED_AFTER_TABLE, expectNoAggregate, findAggregate } from '../validation.js'
7
8
 
8
9
  /**
9
10
  * @import { CTEDefinition, ExprNode, FromSubquery, FromTable, OrderByItem, ParseSqlOptions, ParserState, SelectStatement, SelectColumn, WithClause } from '../types.js'
@@ -223,9 +224,9 @@ export function parseSelectInternal(state) {
223
224
  from = parseFromSubquery(state)
224
225
  } else {
225
226
  // Simple table name: SELECT * FROM users
226
- const table = expectIdentifier(state).value
227
+ const tableTok = expectIdentifier(state)
227
228
  const alias = parseTableAlias(state)
228
- from = { kind: 'table', table, alias }
229
+ from = { kind: 'table', table: tableTok.value, alias, positionStart: tableTok.positionStart, positionEnd: tableTok.positionEnd }
229
230
  }
230
231
 
231
232
  // Parse JOIN clauses
@@ -1,8 +1,8 @@
1
- import { syntaxError } from '../parseErrors.js'
1
+ import { syntaxError } from '../validation/parseErrors.js'
2
2
 
3
3
  /**
4
4
  * @import { ParserState, Token, TokenType } from '../types.js'
5
- * @import { ParseError } from '../parseErrors.js'
5
+ * @import { ParseError } from '../validation/parseErrors.js'
6
6
  */
7
7
 
8
8
  /**
@@ -1,8 +1,4 @@
1
- import {
2
- invalidLiteralError,
3
- unexpectedCharError,
4
- unterminatedError,
5
- } from '../parseErrors.js'
1
+ import { invalidLiteralError, unexpectedCharError, unterminatedError } from '../validation/parseErrors.js'
6
2
 
7
3
  /**
8
4
  * @import { Token } from '../types.d.ts'
@@ -354,10 +350,7 @@ export function tokenizeSql(sql) {
354
350
  continue
355
351
  }
356
352
 
357
- if (tokens.length === 0) {
358
- throw unexpectedCharError({ char: ch, positionStart: pos, expectsSelect: true })
359
- }
360
- throw unexpectedCharError({ char: ch, positionStart: pos })
353
+ throw unexpectedCharError({ char: ch, positionStart: pos, expectsSelect: !tokens.length })
361
354
  }
362
355
 
363
356
  tokens.push({
@@ -1,4 +1,4 @@
1
- import { UserDefinedFunction } from '../types.js'
1
+ import type { UserDefinedFunction } from '../types.js'
2
2
 
3
3
  export interface ParserState {
4
4
  tokens: Token[]
package/src/plan/plan.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { parseSql } from '../parse/parse.js'
2
- import { findAggregate } from '../validation.js'
2
+ import { findAggregate } from '../validation/aggregates.js'
3
+ import { columnNotFoundError, tableNotFoundError } from '../validation/planErrors.js'
3
4
  import { extractColumns } from './columns.js'
4
5
 
5
6
  /**
6
- * @import { ExprNode, DerivedColumn, JoinClause, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement } from '../types.js'
7
+ * @import { AsyncDataSource, ExprNode, DerivedColumn, JoinClause, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement } from '../types.js'
7
8
  * @import { QueryPlan } from './types.d.ts'
8
9
  */
9
10
 
@@ -14,7 +15,7 @@ import { extractColumns } from './columns.js'
14
15
  * @param {PlanSqlOptions} options
15
16
  * @returns {QueryPlan} the root of the query plan tree
16
17
  */
17
- export function planSql({ query, functions }) {
18
+ export function planSql({ query, functions, tables }) {
18
19
  const select = typeof query === 'string' ? parseSql({ query, functions }) : query
19
20
 
20
21
  // Build CTE plans in order (each CTE can reference preceding CTEs)
@@ -22,12 +23,12 @@ export function planSql({ query, functions }) {
22
23
  const ctePlans = new Map()
23
24
  if (select.with) {
24
25
  for (const cte of select.with.ctes) {
25
- const ctePlan = planSelect({ select: cte.query, ctePlans })
26
+ const ctePlan = planSelect({ select: cte.query, ctePlans, tables })
26
27
  ctePlans.set(cte.name.toLowerCase(), ctePlan)
27
28
  }
28
29
  }
29
30
 
30
- return planSelect({ select, ctePlans })
31
+ return planSelect({ select, ctePlans, tables })
31
32
  }
32
33
 
33
34
  /**
@@ -36,9 +37,10 @@ export function planSql({ query, functions }) {
36
37
  * @param {object} options
37
38
  * @param {SelectStatement} options.select
38
39
  * @param {Map<string, QueryPlan>} options.ctePlans
40
+ * @param {Record<string, AsyncDataSource>} [options.tables]
39
41
  * @returns {QueryPlan}
40
42
  */
41
- function planSelect({ select, ctePlans }) {
43
+ function planSelect({ select, ctePlans, tables }) {
42
44
  // Check for aggregation
43
45
  const hasAggregate = select.columns.some(col =>
44
46
  col.kind === 'derived' && findAggregate(col.expr)
@@ -67,11 +69,11 @@ function planSelect({ select, ctePlans }) {
67
69
 
68
70
  // Start with the data source (FROM clause)
69
71
  /** @type {QueryPlan} */
70
- let plan = planFrom({ select, ctePlans, hints })
72
+ let plan = planFrom({ select, ctePlans, hints, tables })
71
73
 
72
74
  // Add JOINs
73
75
  if (select.joins.length) {
74
- plan = planJoin({ left: plan, joins: select.joins, leftTable: sourceAlias, ctePlans, perTableColumns })
76
+ plan = planJoin({ left: plan, joins: select.joins, leftTable: sourceAlias, ctePlans, perTableColumns, tables })
75
77
  }
76
78
 
77
79
  // Whether FROM resolved to our own direct table scan
@@ -153,20 +155,22 @@ function planSelect({ select, ctePlans }) {
153
155
  * @param {SelectStatement} options.select
154
156
  * @param {Map<string, QueryPlan>} options.ctePlans
155
157
  * @param {ScanOptions} options.hints
158
+ * @param {Record<string, AsyncDataSource>} [options.tables]
156
159
  * @returns {QueryPlan}
157
160
  */
158
- function planFrom({ select, ctePlans, hints }) {
161
+ function planFrom({ select, ctePlans, hints, tables }) {
159
162
  if (select.from.kind === 'table') {
160
163
  const ctePlan = ctePlans.get(select.from.table.toLowerCase())
161
164
  if (ctePlan) {
162
165
  return ctePlan
163
166
  }
167
+ validateScan({ ...select.from, hints, tables })
164
168
  return { type: 'Scan', table: select.from.table, hints }
165
169
  } else {
166
170
  if (select.from.query.with) {
167
171
  throw new Error('WITH clause is not supported inside subqueries')
168
172
  }
169
- return planSelect({ select: select.from.query, ctePlans })
173
+ return planSelect({ select: select.from.query, ctePlans, tables })
170
174
  }
171
175
  }
172
176
 
@@ -177,9 +181,10 @@ function planFrom({ select, ctePlans, hints }) {
177
181
  * @param {string} options.leftTable - name/alias of the left table
178
182
  * @param {Map<string, QueryPlan>} options.ctePlans
179
183
  * @param {Map<string, string[] | undefined>} options.perTableColumns
184
+ * @param {Record<string, AsyncDataSource>} [options.tables]
180
185
  * @returns {QueryPlan}
181
186
  */
182
- function planJoin({ left, joins, leftTable, ctePlans, perTableColumns }) {
187
+ function planJoin({ left, joins, leftTable, ctePlans, perTableColumns, tables }) {
183
188
  let plan = left
184
189
  let currentLeftTable = leftTable
185
190
 
@@ -191,6 +196,7 @@ function planJoin({ left, joins, leftTable, ctePlans, perTableColumns }) {
191
196
  const rightHints = {}
192
197
  if (!ctePlan) {
193
198
  rightHints.columns = perTableColumns.get(rightTable)
199
+ validateScan({ ...join, hints: rightHints, tables })
194
200
  }
195
201
  /** @type {QueryPlan} */
196
202
  const rightScan = ctePlan ?? { type: 'Scan', table: join.table, hints: rightHints }
@@ -313,6 +319,33 @@ function extractSimpleJoinKeys({ condition, leftTable, rightTable }) {
313
319
  return { leftKey: left, rightKey: right }
314
320
  }
315
321
 
322
+ /**
323
+ * Validates that a table exists and requested columns are available.
324
+ *
325
+ * @param {object} options
326
+ * @param {string} options.table
327
+ * @param {ScanOptions} options.hints
328
+ * @param {Record<string, AsyncDataSource>} [options.tables]
329
+ * @param {number} options.positionStart
330
+ * @param {number} options.positionEnd
331
+ */
332
+ function validateScan({ table, hints, tables, positionStart, positionEnd }) {
333
+ if (!tables) return
334
+ const resolved = tables[table]
335
+ if (!resolved) {
336
+ throw tableNotFoundError({ table, tables, positionStart, positionEnd })
337
+ }
338
+ const missingColumn = hints.columns?.find(col => !resolved.columns.includes(col))
339
+ if (missingColumn) {
340
+ throw columnNotFoundError({
341
+ columnName: missingColumn,
342
+ availableColumns: resolved.columns,
343
+ positionStart,
344
+ positionEnd,
345
+ })
346
+ }
347
+ }
348
+
316
349
  /**
317
350
  * Checks if every SELECT column is a plain COUNT(*).
318
351
  *
@@ -324,7 +357,7 @@ function isAllCountStar(columns) {
324
357
  return columns.every(col =>
325
358
  col.kind === 'derived' &&
326
359
  col.expr.type === 'function' &&
327
- col.expr.name.toUpperCase() === 'COUNT' &&
360
+ col.expr.funcName.toUpperCase() === 'COUNT' &&
328
361
  col.expr.args.length === 1 &&
329
362
  col.expr.args[0].type === 'star' &&
330
363
  !col.expr.distinct &&
@@ -28,7 +28,7 @@ export function bboxOverlap(a, b) {
28
28
  * @param {SimpleGeometry} geom
29
29
  * @returns {BBox}
30
30
  */
31
- function bbox(geom) {
31
+ export function bbox(geom) {
32
32
  let b = bboxCache.get(geom)
33
33
  if (b) return b
34
34
  if (geom.type === 'Point') {