1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
// SPDX-License-Identifier: Apache-2.0
//! Error types for the nodedb-sql crate.
/// Errors produced during SQL parsing, resolution, or planning.
#[derive(Debug, PartialEq, thiserror::Error)]
pub enum SqlError {
#[error("parse error: {detail}")]
Parse { detail: String },
#[error("table not found: {name}")]
UnknownTable { name: String },
/// A function call in an expression names no scalar, aggregate, or
/// window function registered in the [`FunctionRegistry`](crate::functions::registry::FunctionRegistry).
/// Caught at plan time in the resolver so the whole statement is
/// rejected instead of the call silently evaluating to `NULL` for every
/// row at runtime.
#[error("function {name}(...) does not exist")]
UndefinedFunction { name: String },
/// A sequence accessor appeared in a row-scope clause the row evaluator
/// runs: WHERE, ORDER BY, GROUP BY, HAVING, JOIN ON, SET, an aggregate or
/// window argument, or a subquery.
///
/// Rendered as SQLSTATE `0A000` (feature_not_supported). Constant
/// contexts evaluate the call for real: a FROM-less `SELECT`, a column
/// `DEFAULT`, a `VALUES` list. A SELECT-list item over a FROM relation
/// is evaluated by the Control Plane once per output row. The refusal
/// keeps every other per-row call from reaching the row evaluator, which
/// has no sequence state and would return `NULL` for every row.
#[error(
"{name}(...) is not supported in this clause; over a FROM relation \
only a SELECT-list item that holds no aggregate evaluates it, once \
per output row. Call it there, in a FROM-less SELECT, or in a \
column DEFAULT instead"
)]
SequencePerRowUnsupported { name: String },
/// An index-owned search function (`bm25_score`, `text_match`,
/// `rrf_score`, `sparse_score`, ...) sits in a position the row evaluator
/// runs: the planner could not lower it into its search plan. The
/// function reads a search index and has no per-row value.
///
/// Rendered as SQLSTATE `0A000` (feature_not_supported).
#[error(
"{name}(...) reads a search index and has no per-row value here; \
call it in ORDER BY, in WHERE as the search predicate, or in the \
SELECT list of a search, with a literal query"
)]
SearchFunctionOutsideSearch { name: String },
/// A statement names a database object that does not exist — a sequence,
/// most commonly. Distinct from [`SqlError::UndefinedFunction`]: the
/// function exists, the object it names does not. PostgreSQL rejects the
/// same input with SQLSTATE `42704` (`undefined_object`).
#[error("{kind} \"{name}\" does not exist")]
UndefinedObject { kind: &'static str, name: String },
/// An object exists but a prerequisite step has not run, such as `currval`
/// before this session called `nextval`. PostgreSQL rejects the same input
/// with SQLSTATE `55000` (`object_not_in_prerequisite_state`).
#[error("{detail}")]
ObjectNotInPrerequisiteState { object: String, detail: String },
/// A statement the named object's kind does not take, such as a write to
/// a read-only continuous aggregate. PostgreSQL rejects the same input
/// with SQLSTATE `42809` (`wrong_object_type`).
#[error("{detail}")]
WrongObjectType { detail: String },
#[error("unknown column '{column}' in table '{table}'")]
UnknownColumn { table: String, column: String },
/// The column argument of `text_match` / `bm25_score` cannot serve a
/// full-text search. `fault` names why and carries the SQLSTATE.
#[error("{function}(): column '{column}' of collection '{collection}' {fault}")]
TextColumn {
function: String,
collection: String,
column: String,
fault: nodedb_types::text_search::TextColumnFault,
},
#[error("ambiguous column '{column}' — qualify with table name")]
AmbiguousColumn { column: String },
#[error("type mismatch: {detail}")]
TypeMismatch { detail: String },
/// An element of an array bound for a declared `VECTOR(dim)` column is
/// not a number. Rendered as SQLSTATE `42804` (datatype_mismatch).
/// `element` names the refused element's kind, and its text for a string.
#[error("column '{column}': expected a number for VECTOR({dim}), got {element}")]
VectorElementNotNumeric {
column: String,
dim: usize,
element: String,
},
/// A statement lists a different number of targets than expressions, such
/// as an `INSERT` whose target column list does not match its `SELECT`
/// list.
#[error("{detail}")]
Arity { detail: String },
/// A constant expression divided by zero at plan time. Distinct from the
/// folder declining to fold: the expression *was* constant and evaluating
/// it failed, so the statement must raise rather than yield NULL.
#[error("division by zero")]
DivisionByZero,
/// A constant function call received an argument it cannot compute on:
/// vectors of different dimensions, an argument of the wrong type, a
/// malformed JSONPath. Same distinction as [`SqlError::DivisionByZero`].
/// Rendered as SQLSTATE `22000` (`data_exception`).
#[error("{detail}")]
DataException { detail: String },
/// A LIMIT, OFFSET, or FETCH FIRST clause resolved to a value outside
/// `[0, usize::MAX]`, or to an expression the planner cannot read as a
/// literal. PostgreSQL rejects the same input with SQLSTATE `2201W`.
#[error("invalid {clause} value: {value}")]
InvalidLimitValue { clause: &'static str, value: String },
/// A constant expression overflowed at plan time. Same distinction as
/// [`SqlError::DivisionByZero`].
#[error("arithmetic overflow evaluating a constant expression: {detail}")]
ConstantOverflow { detail: String },
/// An integer literal past the exact numeric range (the 96-bit
/// `Decimal` mantissa). Rounding it to a float would store another
/// number than the one written. PostgreSQL rejects an out-of-range
/// numeric with SQLSTATE `22003`.
#[error("numeric literal {literal} is out of range")]
NumericLiteralOutOfRange { literal: String },
/// A write supplied an integer wider than the column's declared type.
///
/// nodedb stores every integer as an `i64`, so this is not a storage
/// limit — it is the constraint that makes the column's advertised wire
/// type honest. A column declared `INTEGER` reports OID 23, and a pgwire
/// client reading it in binary format decodes exactly four bytes; letting
/// a wider value in would mean either truncating it on read or lying about
/// the type. PostgreSQL rejects the same write with the same message.
#[error("value {value} is out of range for column '{column}' of type {declared_type}")]
IntegerOutOfRange {
column: String,
value: i64,
declared_type: &'static str,
},
/// A write supplied a finite float that overflows to infinity when
/// narrowed to the column's declared `REAL` width.
///
/// Narrowing an `f64` to `f32` normally rounds — `1.1` becoming
/// `1.10000002` is correct PostgreSQL `real` behaviour, never an error —
/// so this is not the float mirror of [`SqlError::IntegerOutOfRange`]'s
/// full-range check. The single case rejected is a finite value beyond
/// `f32`'s range, which would otherwise silently become `Infinity`.
/// PostgreSQL rejects the same write with the same message.
#[error("value {value} is out of range for column '{column}' of type {declared_type}")]
FloatOutOfRange {
column: String,
value: f64,
declared_type: &'static str,
},
/// A write supplied a decimal whose rounded integer part has more digits
/// than the column's declared `DECIMAL(p,s)` holds. PostgreSQL refuses
/// the same write with SQLSTATE `22003`.
#[error("column '{column}': {source}")]
DecimalOutOfRange {
column: String,
source: nodedb_types::columnar::DecimalOutOfRange,
},
#[error("unsupported: {detail}")]
Unsupported { detail: String },
/// A declared column DEFAULT the server cannot evaluate to a value.
///
/// The column is never omitted instead. A DEFAULT that disappears stores
/// NULL where the declaration promised a value, and nothing reports it.
#[error("DEFAULT for column '{column}' cannot be evaluated: {expr}")]
UnevaluableDefault { column: String, expr: String },
/// `setval` appeared inside a column DEFAULT.
///
/// A DEFAULT runs once per row, so evaluating `setval` there will move the
/// sequence's position on every inserted row. PostgreSQL reports a
/// function used in a context that forbids it as SQLSTATE `42601`
/// (`syntax_error`), and this refusal carries the same code.
#[error(
"setval() is not allowed in the DEFAULT for column '{column}'; \
a DEFAULT must not move a sequence's position"
)]
SetvalInColumnDefault { column: String },
#[error("invalid function call: {detail}")]
InvalidFunction { detail: String },
#[error("invalid window frame: {detail}")]
InvalidWindowFrame { detail: String },
#[error("missing required field '{field}' for {context}")]
MissingField { field: String, context: String },
/// A positional `INSERT`/`UPSERT` `VALUES` row (no explicit column
/// list) supplied more values than the target collection has declared
/// columns. Binding the overflow value(s) to a synthetic `col{N}` name
/// would silently store them under an unaddressable column, so this is
/// rejected rather than guessed at.
#[error(
"INSERT/UPSERT into '{collection}': row has {given} value(s) but only \
{declared} column(s) are declared; supply an explicit column list \
or remove the extra value(s)"
)]
InsertColumnArityMismatch {
collection: String,
given: usize,
declared: usize,
},
/// A positional `INSERT`/`UPSERT` into a Key-Value collection supplied
/// no explicit column list. The KV path splits key/value by matching
/// column *names* against `pk_col`/`"key"`/`"ttl"`, so without a column
/// list there is no principled key/value split — binding positionally
/// would silently write an empty-keyed, empty-valued row (all such rows
/// collide). Rejected rather than guessed at, mirroring
/// [`InsertColumnArityMismatch`].
#[error(
"INSERT/UPSERT into KV collection '{collection}': supply an explicit \
column list (KV needs named key/value columns; a positional VALUES \
row has no key to bind to)"
)]
PositionalKvInsertUnsupported { collection: String },
/// A descriptor the planner depends on is being drained by
/// an in-flight DDL. Callers (pgwire handlers) should retry
/// the whole statement after a short backoff. Propagated
/// from `SqlCatalogError::RetryableSchemaChanged`.
#[error("retryable schema change on {descriptor}")]
RetryableSchemaChanged { descriptor: String },
/// Identifier violates NodeDB's canonical identifier rules.
#[error("invalid identifier '{name}': {reason}")]
InvalidIdentifier { name: String, reason: &'static str },
/// Identifier is a NodeDB reserved keyword. Use a quoted identifier to bypass.
#[error(
"identifier '{name}' is reserved by NodeDB ({reason}); \
use a quoted identifier (e.g., \"{name}\") to bypass"
)]
ReservedIdentifier { name: String, reason: &'static str },
/// An unsupported SQL constraint was used in a DDL statement.
///
/// Rendered as SQLSTATE `0A000` (feature_not_supported). The `feature` field
/// names the constraint keyword and `hint` points to the NodeDB equivalent.
#[error("unsupported constraint: {feature}; {hint}")]
UnsupportedConstraint { feature: String, hint: String },
/// Both a `WITH (engine='...')` clause and a trailing `ENGINE = ...`
/// suffix were given on the same `CREATE COLLECTION` / `CREATE TABLE`
/// statement, naming different engines.
///
/// Rendered as SQLSTATE `0A000` (feature_not_supported), matching the
/// existing duplicate-axis rejection precedent for `WITH (profile=...)`.
#[error(
"conflicting engine selection: WITH (engine='{with_engine}') and \
ENGINE = {suffix_engine} name different engines; only one engine-selection \
clause is allowed"
)]
ConflictingEngineClause {
with_engine: String,
suffix_engine: String,
},
/// WITH RECURSIVE used a set operator other than UNION or UNION ALL.
///
/// Only `UNION` and `UNION ALL` are permitted in the recursive term of a
/// `WITH RECURSIVE` CTE. `INTERSECT` and `EXCEPT` are rejected because
/// they cannot guarantee termination in standard iterative evaluation.
#[error(
"WITH RECURSIVE: only UNION / UNION ALL are allowed in the recursive term; \
{op} is not permitted"
)]
InvalidRecursiveSetOp { op: String },
/// The recursive self-reference is absent, appears more than once, or
/// appears inside a subquery, aggregate, or the nullable side of an outer join.
#[error("WITH RECURSIVE: invalid self-reference to '{cte_name}' in recursive term: {reason}")]
InvalidRecursiveSelfRef { cte_name: String, reason: String },
/// The anchor SELECT produces a different number of columns than the
/// column list declared on the CTE (or the recursive arm).
#[error(
"WITH RECURSIVE CTE '{cte_name}': anchor produces {anchor_cols} column(s) \
but {declared_cols} were declared"
)]
RecursiveColumnMismatch {
cte_name: String,
anchor_cols: usize,
declared_cols: usize,
},
/// Two of the CTE's columns carry the same name, either from a repeated
/// entry in the declared column list or from two anchor items that infer
/// the same output name. Rows are keyed by column name, so a duplicate
/// drops one column's values instead of returning both.
#[error(
"WITH RECURSIVE CTE '{cte_name}': column '{column}' is declared or inferred \
more than once; give each column a distinct name"
)]
DuplicateRecursiveColumn { cte_name: String, column: String },
/// The recursive CTE exceeded the configured `max_recursion_depth`.
///
/// This is a runtime error produced by the executor, not the planner.
#[error(
"WITH RECURSIVE CTE '{cte_name}' exceeded max recursion depth {max_depth}; \
add a stricter termination condition or raise max_recursion_depth"
)]
RecursionDepthExceeded { cte_name: String, max_depth: usize },
/// Collection is soft-deleted (within retention window).
/// Propagated from `SqlCatalogError::CollectionDeactivated`;
/// the pgwire layer renders this as sqlstate 42P01 with an
/// `UNDROP COLLECTION <name>` hint in the message.
#[error(
"collection '{name}' was dropped; \
restore with `{undrop_hint}` before retention elapses \
at {retention_expires_at_ns} ns"
)]
CollectionDeactivated {
name: String,
retention_expires_at_ns: u64,
undrop_hint: String,
},
}
impl From<crate::catalog::SqlCatalogError> for SqlError {
fn from(e: crate::catalog::SqlCatalogError) -> Self {
match e {
crate::catalog::SqlCatalogError::RetryableSchemaChanged { descriptor } => {
Self::RetryableSchemaChanged { descriptor }
}
crate::catalog::SqlCatalogError::CollectionDeactivated {
name,
retention_expires_at_ns,
} => {
let undrop_hint = format!("UNDROP COLLECTION {name}");
Self::CollectionDeactivated {
name,
retention_expires_at_ns,
undrop_hint,
}
}
crate::catalog::SqlCatalogError::Unreadable { relation, detail } => {
Self::ObjectNotInPrerequisiteState {
object: format!("relation '{relation}'"),
detail: format!("relation '{relation}' could not be read: {detail}"),
}
}
}
}
}
impl From<nodedb_query::expr_parse::ExprParseError> for SqlError {
fn from(e: nodedb_query::expr_parse::ExprParseError) -> Self {
Self::Parse {
detail: e.to_string(),
}
}
}
impl From<sqlparser::parser::ParserError> for SqlError {
fn from(e: sqlparser::parser::ParserError) -> Self {
Self::Parse {
detail: e.to_string(),
}
}
}
pub type Result<T> = std::result::Result<T, SqlError>;