Skip to main content

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>;