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    /// A statement failed inside an explicit transaction. Only ROLLBACK may
135    /// proceed; a later COMMIT must not publish a partially executed statement.
136    #[error("explicit transaction is aborted; roll back before running another statement")]
137    TransactionAborted,
138    /// Recovery of a failed mutation could not establish a usable live state.
139    #[error(
140        "database handle is unusable after a storage failure; close and reopen before continuing"
141    )]
142    EnginePoisoned,
143    /// A commit encountered an I/O failure after entering its durability path.
144    /// The caller must reconcile after reopening instead of blindly retrying.
145    #[error("commit outcome is unknown after a storage failure; close and reopen, then reconcile before retrying")]
146    CommitOutcomeUnknown,
147    /// Generic execution error (catch-all for migration).
148    #[error("{0}")]
149    Execution(String),
150}
151
152/// Display body for [`QueryError::ColumnNotFound`]: the table name is
153/// optional, and the historical message omits the trailing clause when it
154/// is empty.
155fn column_not_found_message(table: &str, column: &str) -> String {
156    if table.is_empty() {
157        format!("column '{column}' not found")
158    } else {
159        format!("column '{column}' not found in table '{table}'")
160    }
161}
162
163/// Display body for [`QueryError::NestedLoopPairLimitExceeded`]: the pair
164/// count is reported exactly when it fits in usize and as an overflow note
165/// otherwise.
166fn nested_loop_pair_limit_message(left_rows: usize, right_rows: usize, limit: usize) -> String {
167    match left_rows.checked_mul(right_rows) {
168        Some(pairs) => format!(
169            "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"
170        ),
171        None => format!(
172            "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"
173        ),
174    }
175}
176
177impl QueryError {
178    /// Wrap a storage failure that arrived as an [`std::io::Error`], keeping
179    /// its [`StorageErrorKind`] when the storage engine attached one.
180    ///
181    /// Most of the storage engine speaks `io::Result`, and a typed refusal
182    /// rides inside the `io::Error` as its source. Recovering it here is what
183    /// lets the server pick a wire error class from the error's type instead
184    /// of searching its message for a known phrase. A plain I/O failure, or a
185    /// refusal that was raised as a bare string, has no kind to recover and
186    /// falls back to [`QueryError::StorageError`] with byte-identical text.
187    pub fn from_storage_io(error: std::io::Error) -> Self {
188        match StorageError::kind_of_io_error(&error) {
189            Some(kind) => QueryError::Storage {
190                kind,
191                message: error.to_string(),
192            },
193            None => QueryError::StorageError(error.to_string()),
194        }
195    }
196}
197
198impl From<StorageError> for QueryError {
199    fn from(error: StorageError) -> Self {
200        QueryError::Storage {
201            kind: error.kind(),
202            message: error.to_string(),
203        }
204    }
205}
206
207impl From<String> for QueryError {
208    fn from(s: String) -> Self {
209        QueryError::Execution(s)
210    }
211}
212
213impl From<&str> for QueryError {
214    fn from(s: &str) -> Self {
215        QueryError::Execution(s.to_string())
216    }
217}