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}