powdb_query/result.rs
1use powdb_storage::types::Value;
2
3/// The result of executing a query.
4#[derive(Debug)]
5pub enum QueryResult {
6 Rows {
7 columns: Vec<String>,
8 rows: Vec<Vec<Value>>,
9 },
10 Scalar(Value), // count, avg, etc.
11 Modified(u64), // insert/update/delete — number of rows affected
12 Created(String), // DDL — type name created
13 Executed {
14 message: String,
15 }, // DDL — alter/drop feedback
16}
17
18impl QueryResult {
19 pub fn row_count(&self) -> usize {
20 match self {
21 QueryResult::Rows { rows, .. } => rows.len(),
22 QueryResult::Scalar(_) => 1,
23 QueryResult::Modified(n) => *n as usize,
24 QueryResult::Created(_) => 0,
25 QueryResult::Executed { .. } => 0,
26 }
27 }
28}
29
30/// Typed error enum for query execution failures.
31///
32/// Replaces the previous `Result<QueryResult, String>` pattern with
33/// structured variants that callers can programmatically match on.
34/// The `From<String>` impl enables gradual migration: existing
35/// `Err(format!(...))` sites continue to compile via `?` propagation.
36///
37/// Display strings are wire-visible behavior: the server's egress
38/// sanitization prefix-matches them and clients assert on them. Every
39/// message is pinned byte-exact by `tests/error_display.rs`; do not
40/// reword one without updating that suite deliberately.
41#[derive(Debug, Clone, PartialEq, thiserror::Error)]
42pub enum QueryError {
43 /// Table does not exist.
44 #[error("table '{0}' not found")]
45 TableNotFound(String),
46 /// Column does not exist on table.
47 #[error("{}", column_not_found_message(table, column))]
48 ColumnNotFound { table: String, column: String },
49 /// Type mismatch in expression.
50 #[error("type mismatch: {0}")]
51 TypeError(String),
52 /// Join result exceeded MAX_JOIN_ROWS.
53 #[error("join result exceeds row limit")]
54 JoinLimitExceeded,
55 /// A fallback nested-loop join would evaluate more candidate pairs than
56 /// the measured safety cap.
57 #[error("{}", nested_loop_pair_limit_message(*left_rows, *right_rows, *limit))]
58 NestedLoopPairLimitExceeded {
59 left_rows: usize,
60 right_rows: usize,
61 limit: usize,
62 },
63 /// Sort exceeded MAX_SORT_ROWS.
64 #[error("sort input exceeds row limit \u{2014} add a LIMIT clause")]
65 SortLimitExceeded,
66 /// Per-query memory budget exceeded during materialization (sort buffer,
67 /// join build side, GROUP BY hash table, or IN-list). Returned cleanly so
68 /// the server process is never OOM-killed by a crafted query.
69 #[error(
70 "query exceeded memory budget: requested {requested_bytes} bytes, limit {limit_bytes} bytes"
71 )]
72 MemoryLimitExceeded {
73 limit_bytes: usize,
74 requested_bytes: usize,
75 },
76 /// Parse error (wraps parser error).
77 #[error("{0}")]
78 Parse(String),
79 /// Index-related error.
80 #[error("{0}")]
81 IndexError(String),
82 /// View-related error.
83 #[error("{0}")]
84 ViewError(String),
85 /// WAL or I/O error.
86 #[error("{0}")]
87 StorageError(String),
88 /// Readonly path needs write lock (internal sentinel). The server
89 /// intercepts this variant before Display, so the sentinel string must
90 /// never cross the wire.
91 #[error("__POWDB_READONLY_NEEDS_WRITE__")]
92 ReadonlyNeedsWrite,
93 /// The engine was opened read-only (snapshot serving) and the statement
94 /// requires a writer. Unlike [`QueryError::ReadonlyNeedsWrite`], this is a
95 /// terminal error with an operator-facing message: there is no writer to
96 /// escalate to in this mode.
97 #[error(
98 "readonly mode: statement requires a writer (this database was opened read-only for snapshot serving; refresh materialized views before snapshotting a read-only directory)"
99 )]
100 ReadonlyMode,
101 /// The per-query deadline elapsed before execution finished. Returned as a
102 /// clean early-return from an unbounded executor loop so the query releases
103 /// its locks instead of running to completion. `timeout_ms` is the
104 /// configured per-query timeout.
105 #[error("query timeout after {timeout_ms}ms")]
106 Timeout { timeout_ms: u64 },
107 /// Execution was cancelled cooperatively (e.g. the issuing client
108 /// disconnected). Like [`QueryError::Timeout`], a clean early-return.
109 #[error("query cancelled by client disconnect")]
110 Cancelled,
111 /// Generic execution error (catch-all for migration).
112 #[error("{0}")]
113 Execution(String),
114}
115
116/// Display body for [`QueryError::ColumnNotFound`]: the table name is
117/// optional, and the historical message omits the trailing clause when it
118/// is empty.
119fn column_not_found_message(table: &str, column: &str) -> String {
120 if table.is_empty() {
121 format!("column '{column}' not found")
122 } else {
123 format!("column '{column}' not found in table '{table}'")
124 }
125}
126
127/// Display body for [`QueryError::NestedLoopPairLimitExceeded`]: the pair
128/// count is reported exactly when it fits in usize and as an overflow note
129/// otherwise.
130fn nested_loop_pair_limit_message(left_rows: usize, right_rows: usize, limit: usize) -> String {
131 match left_rows.checked_mul(right_rows) {
132 Some(pairs) => format!(
133 "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"
134 ),
135 None => format!(
136 "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"
137 ),
138 }
139}
140
141impl From<String> for QueryError {
142 fn from(s: String) -> Self {
143 QueryError::Execution(s)
144 }
145}
146
147impl From<&str> for QueryError {
148 fn from(s: &str) -> Self {
149 QueryError::Execution(s.to_string())
150 }
151}