Skip to main content

powdb_query/
result.rs

1use powdb_storage::error::{StorageError, StorageErrorKind};
2use powdb_storage::types::Value;
3
4/// The result of executing a query.
5#[derive(Debug)]
6pub enum QueryResult {
7    Rows {
8        columns: Vec<String>,
9        rows: Vec<Vec<Value>>,
10    },
11    Scalar(Value),   // count, avg, etc.
12    Modified(u64),   // insert/update/delete — number of rows affected
13    Created(String), // DDL — type name created
14    Executed {
15        message: String,
16    }, // DDL — alter/drop feedback
17}
18
19impl QueryResult {
20    pub fn row_count(&self) -> usize {
21        match self {
22            QueryResult::Rows { rows, .. } => rows.len(),
23            QueryResult::Scalar(_) => 1,
24            QueryResult::Modified(n) => *n as usize,
25            QueryResult::Created(_) => 0,
26            QueryResult::Executed { .. } => 0,
27        }
28    }
29}
30
31/// Typed error enum for query execution failures.
32///
33/// Replaces the previous `Result<QueryResult, String>` pattern with
34/// structured variants that callers can programmatically match on.
35/// The `From<String>` impl enables gradual migration: existing
36/// `Err(format!(...))` sites continue to compile via `?` propagation.
37///
38/// Display strings are wire-visible behavior: the server's egress
39/// sanitization prefix-matches them and clients assert on them. Every
40/// message is pinned byte-exact by `tests/error_display.rs`; do not
41/// reword one without updating that suite deliberately.
42#[derive(Debug, Clone, PartialEq, thiserror::Error)]
43pub enum QueryError {
44    /// Table does not exist.
45    #[error("table '{0}' not found")]
46    TableNotFound(String),
47    /// Column does not exist on table.
48    #[error("{}", column_not_found_message(table, column))]
49    ColumnNotFound { table: String, column: String },
50    /// Type mismatch in expression.
51    #[error("type mismatch: {0}")]
52    TypeError(String),
53    /// Join result exceeded MAX_JOIN_ROWS.
54    #[error("join result exceeds row limit")]
55    JoinLimitExceeded,
56    /// A fallback nested-loop join would evaluate more candidate pairs than
57    /// the measured safety cap.
58    #[error("{}", nested_loop_pair_limit_message(*left_rows, *right_rows, *limit))]
59    NestedLoopPairLimitExceeded {
60        left_rows: usize,
61        right_rows: usize,
62        limit: usize,
63    },
64    /// Sort exceeded MAX_SORT_ROWS.
65    #[error("sort input exceeds row limit \u{2014} add a LIMIT clause")]
66    SortLimitExceeded,
67    /// Per-query memory budget exceeded during materialization (sort buffer,
68    /// join build side, GROUP BY hash table, or IN-list). Returned cleanly so
69    /// the server process is never OOM-killed by a crafted query.
70    #[error(
71        "query exceeded memory budget: requested {requested_bytes} bytes, limit {limit_bytes} bytes"
72    )]
73    MemoryLimitExceeded {
74        limit_bytes: usize,
75        requested_bytes: usize,
76    },
77    /// Parse error (wraps parser error).
78    #[error("{0}")]
79    Parse(String),
80    /// Index-related error.
81    #[error("{0}")]
82    IndexError(String),
83    /// View-related error.
84    #[error("{0}")]
85    ViewError(String),
86    /// WAL or I/O error whose originating variant was already discarded.
87    ///
88    /// Prefer [`QueryError::Storage`]: this variant keeps only the rendered
89    /// text, so anything downstream that needs to know *what* failed (the
90    /// server picks a wire error class from it) is left matching substrings.
91    #[error("{0}")]
92    StorageError(String),
93    /// A storage-engine refusal that reached the query layer with its kind
94    /// intact.
95    ///
96    /// `message` is the storage error rendered exactly as
97    /// [`QueryError::StorageError`] would have rendered it, so this variant is
98    /// Display-identical to the untyped one and changes nothing a client
99    /// reads. `kind` is the part that was previously thrown away, and it is
100    /// what the server classifies on.
101    ///
102    /// The kind travels beside the message rather than the
103    /// [`StorageError`] itself because that type wraps
104    /// [`std::io::Error`], which is neither `Clone` nor `PartialEq`, and
105    /// `QueryError` is both.
106    #[error("{message}")]
107    Storage {
108        kind: StorageErrorKind,
109        message: String,
110    },
111    /// Readonly path needs write lock (internal sentinel). The server
112    /// intercepts this variant before Display, so the sentinel string must
113    /// never cross the wire.
114    #[error("__POWDB_READONLY_NEEDS_WRITE__")]
115    ReadonlyNeedsWrite,
116    /// The engine was opened read-only (snapshot serving) and the statement
117    /// requires a writer. Unlike [`QueryError::ReadonlyNeedsWrite`], this is a
118    /// terminal error with an operator-facing message: there is no writer to
119    /// escalate to in this mode.
120    #[error(
121        "readonly mode: statement requires a writer (this database was opened read-only for snapshot serving; refresh materialized views before snapshotting a read-only directory)"
122    )]
123    ReadonlyMode,
124    /// The per-query deadline elapsed before execution finished. Returned as a
125    /// clean early-return from an unbounded executor loop so the query releases
126    /// its locks instead of running to completion. `timeout_ms` is the
127    /// configured per-query timeout.
128    #[error("query timeout after {timeout_ms}ms")]
129    Timeout { timeout_ms: u64 },
130    /// Execution was cancelled cooperatively (e.g. the issuing client
131    /// disconnected). Like [`QueryError::Timeout`], a clean early-return.
132    #[error("query cancelled by client disconnect")]
133    Cancelled,
134    /// Generic execution error (catch-all for migration).
135    #[error("{0}")]
136    Execution(String),
137}
138
139/// Display body for [`QueryError::ColumnNotFound`]: the table name is
140/// optional, and the historical message omits the trailing clause when it
141/// is empty.
142fn column_not_found_message(table: &str, column: &str) -> String {
143    if table.is_empty() {
144        format!("column '{column}' not found")
145    } else {
146        format!("column '{column}' not found in table '{table}'")
147    }
148}
149
150/// Display body for [`QueryError::NestedLoopPairLimitExceeded`]: the pair
151/// count is reported exactly when it fits in usize and as an overflow note
152/// otherwise.
153fn nested_loop_pair_limit_message(left_rows: usize, right_rows: usize, limit: usize) -> String {
154    match left_rows.checked_mul(right_rows) {
155        Some(pairs) => format!(
156            "nested-loop join would evaluate {pairs} candidate pairs, above the {limit} pair limit; add an equi-key to ON, index/filter an input, reduce the joined row counts, or raise the cap via POWDB_MAX_NESTED_LOOP_PAIRS"
157        ),
158        None => format!(
159            "nested-loop join candidate count overflows usize ({left_rows} x {right_rows}), above the {limit} pair limit; add an equi-key to ON, index/filter an input, reduce the joined row counts, or raise the cap via POWDB_MAX_NESTED_LOOP_PAIRS"
160        ),
161    }
162}
163
164impl QueryError {
165    /// Wrap a storage failure that arrived as an [`std::io::Error`], keeping
166    /// its [`StorageErrorKind`] when the storage engine attached one.
167    ///
168    /// Most of the storage engine speaks `io::Result`, and a typed refusal
169    /// rides inside the `io::Error` as its source. Recovering it here is what
170    /// lets the server pick a wire error class from the error's type instead
171    /// of searching its message for a known phrase. A plain I/O failure, or a
172    /// refusal that was raised as a bare string, has no kind to recover and
173    /// falls back to [`QueryError::StorageError`] with byte-identical text.
174    pub fn from_storage_io(error: std::io::Error) -> Self {
175        match StorageError::kind_of_io_error(&error) {
176            Some(kind) => QueryError::Storage {
177                kind,
178                message: error.to_string(),
179            },
180            None => QueryError::StorageError(error.to_string()),
181        }
182    }
183}
184
185impl From<StorageError> for QueryError {
186    fn from(error: StorageError) -> Self {
187        QueryError::Storage {
188            kind: error.kind(),
189            message: error.to_string(),
190        }
191    }
192}
193
194impl From<String> for QueryError {
195    fn from(s: String) -> Self {
196        QueryError::Execution(s)
197    }
198}
199
200impl From<&str> for QueryError {
201    fn from(s: &str) -> Self {
202        QueryError::Execution(s.to_string())
203    }
204}