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