1use std::error::Error as StdError;
7use std::fmt;
8
9use crate::catalog::CatalogError;
10use crate::error::ParserError;
11use crate::executor::ExecutorError;
12use crate::planner::PlannerError;
13use crate::storage::StorageError;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub struct ErrorLocation {
18 pub line: u64,
19 pub column: u64,
20}
21
22impl ErrorLocation {
23 pub fn is_known(&self) -> bool {
25 self.line > 0 || self.column > 0
26 }
27}
28
29impl fmt::Display for ErrorLocation {
30 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31 write!(f, "line {}, column {}", self.line, self.column)
32 }
33}
34
35#[derive(Debug)]
47pub enum SqlError {
48 Parse {
50 message: String,
51 location: ErrorLocation,
52 code: &'static str,
53 },
54
55 Plan {
57 message: String,
58 location: ErrorLocation,
59 code: &'static str,
60 },
61
62 Execution { message: String, code: &'static str },
64
65 Storage {
67 message: String,
68 code: &'static str,
69 source: Option<alopex_core::Error>,
70 },
71
72 Catalog {
74 message: String,
75 location: ErrorLocation,
76 code: &'static str,
77 },
78}
79
80impl SqlError {
81 pub fn code(&self) -> &'static str {
83 match self {
84 Self::Parse { code, .. }
85 | Self::Plan { code, .. }
86 | Self::Execution { code, .. }
87 | Self::Storage { code, .. }
88 | Self::Catalog { code, .. } => code,
89 }
90 }
91
92 pub fn message(&self) -> &str {
94 match self {
95 Self::Parse { message, .. }
96 | Self::Plan { message, .. }
97 | Self::Execution { message, .. }
98 | Self::Storage { message, .. }
99 | Self::Catalog { message, .. } => message,
100 }
101 }
102
103 pub fn location(&self) -> ErrorLocation {
105 match self {
106 Self::Parse { location, .. }
107 | Self::Plan { location, .. }
108 | Self::Catalog { location, .. } => *location,
109 Self::Execution { .. } | Self::Storage { .. } => ErrorLocation::default(),
110 }
111 }
112
113 pub fn message_with_location(&self) -> String {
115 let code = self.code();
116 let message = self.message();
117 let location = self.location();
118
119 match self {
120 Self::Storage { .. } => format!("error[{code}]: storage error: {message}"),
121 _ if location.is_known() => format!(
122 "error[{code}]: {message} at line {}, column {}",
123 location.line, location.column
124 ),
125 _ => format!("error[{code}]: {message}"),
126 }
127 }
128}
129
130impl fmt::Display for SqlError {
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 f.write_str(&self.message_with_location())
133 }
134}
135
136impl StdError for SqlError {
137 fn source(&self) -> Option<&(dyn StdError + 'static)> {
138 match self {
139 Self::Storage {
140 source: Some(source),
141 ..
142 } => Some(source),
143 _ => None,
144 }
145 }
146}
147
148impl From<alopex_core::Error> for SqlError {
149 fn from(e: alopex_core::Error) -> Self {
150 let code = match e {
151 alopex_core::Error::TxnConflict => "ALOPEX-S001",
152 alopex_core::Error::TxnClosed => "ALOPEX-S002",
153 alopex_core::Error::TxnReadOnly => "ALOPEX-S003",
154 _ => "ALOPEX-S999",
155 };
156
157 Self::Storage {
158 message: e.to_string(),
159 code,
160 source: Some(e),
161 }
162 }
163}
164
165impl From<ParserError> for SqlError {
166 fn from(value: ParserError) -> Self {
167 match value {
168 ParserError::UnexpectedToken {
169 line,
170 column,
171 expected,
172 found,
173 } => Self::Parse {
174 message: format!("unexpected token: expected {expected}, found {found}"),
175 location: ErrorLocation { line, column },
176 code: "ALOPEX-P001",
177 },
178 ParserError::ExpectedToken {
179 line,
180 column,
181 expected,
182 found,
183 } => Self::Parse {
184 message: format!("expected {expected} but found {found}"),
185 location: ErrorLocation { line, column },
186 code: "ALOPEX-P002",
187 },
188 ParserError::UnterminatedString { line, column } => Self::Parse {
189 message: "unterminated string literal".to_string(),
190 location: ErrorLocation { line, column },
191 code: "ALOPEX-P003",
192 },
193 ParserError::InvalidNumber {
194 line,
195 column,
196 value,
197 } => Self::Parse {
198 message: format!("invalid number literal '{value}'"),
199 location: ErrorLocation { line, column },
200 code: "ALOPEX-P004",
201 },
202 ParserError::InvalidVector { line, column } => Self::Parse {
203 message: "invalid vector literal".to_string(),
204 location: ErrorLocation { line, column },
205 code: "ALOPEX-P005",
206 },
207 ParserError::RecursionLimitExceeded { depth } => Self::Parse {
208 message: format!("recursion limit exceeded (depth: {depth})"),
209 location: ErrorLocation::default(),
210 code: "ALOPEX-P006",
211 },
212 }
213 }
214}
215
216impl From<PlannerError> for SqlError {
217 fn from(value: PlannerError) -> Self {
218 match value {
219 PlannerError::TableNotFound { name, line, column } => Self::Catalog {
220 message: format!("table '{name}' not found"),
221 location: ErrorLocation { line, column },
222 code: "ALOPEX-C001",
223 },
224 PlannerError::TableAlreadyExists { name } => Self::Catalog {
225 message: format!("table '{name}' already exists"),
226 location: ErrorLocation::default(),
227 code: "ALOPEX-C002",
228 },
229 PlannerError::ColumnNotFound {
230 column,
231 table,
232 line,
233 col,
234 } => Self::Catalog {
235 message: format!("column '{column}' not found in table '{table}'"),
236 location: ErrorLocation { line, column: col },
237 code: "ALOPEX-C003",
238 },
239 PlannerError::AmbiguousColumn {
240 column,
241 tables,
242 line,
243 col,
244 } => Self::Catalog {
245 message: format!("ambiguous column '{column}' found in tables: {tables:?}"),
246 location: ErrorLocation { line, column: col },
247 code: "ALOPEX-C004",
248 },
249 PlannerError::IndexAlreadyExists { name } => Self::Catalog {
250 message: format!("index '{name}' already exists"),
251 location: ErrorLocation::default(),
252 code: "ALOPEX-C005",
253 },
254 PlannerError::IndexNotFound { name } => Self::Catalog {
255 message: format!("index '{name}' not found"),
256 location: ErrorLocation::default(),
257 code: "ALOPEX-C006",
258 },
259 PlannerError::TypeMismatch {
260 expected,
261 found,
262 line,
263 column,
264 } => Self::Plan {
265 message: format!("type mismatch: expected {expected}, found {found}"),
266 location: ErrorLocation { line, column },
267 code: "ALOPEX-T001",
268 },
269 PlannerError::InvalidOperator {
270 op,
271 type_name,
272 line,
273 column,
274 } => Self::Plan {
275 message: format!("invalid operator '{op}' for type '{type_name}'"),
276 location: ErrorLocation { line, column },
277 code: "ALOPEX-T002",
278 },
279 PlannerError::NullConstraintViolation { column, line, col } => Self::Plan {
280 message: format!("null constraint violation for column '{column}'"),
281 location: ErrorLocation { line, column: col },
282 code: "ALOPEX-T003",
283 },
284 PlannerError::VectorDimensionMismatch {
285 expected,
286 found,
287 line,
288 column,
289 } => Self::Plan {
290 message: format!("vector dimension mismatch: expected {expected}, found {found}"),
291 location: ErrorLocation { line, column },
292 code: "ALOPEX-T004",
293 },
294 PlannerError::InvalidMetric {
295 value,
296 line,
297 column,
298 } => Self::Plan {
299 message: format!("invalid metric '{value}' (valid: cosine, l2, inner)"),
300 location: ErrorLocation { line, column },
301 code: "ALOPEX-T005",
302 },
303 PlannerError::ColumnValueCountMismatch {
304 columns,
305 values,
306 line,
307 column,
308 } => Self::Plan {
309 message: format!("column count ({columns}) does not match value count ({values})"),
310 location: ErrorLocation { line, column },
311 code: "ALOPEX-T006",
312 },
313 PlannerError::UnsupportedFeature {
314 feature,
315 version,
316 line,
317 column,
318 } => Self::Plan {
319 message: format!("feature '{feature}' is not supported (expected in {version})"),
320 location: ErrorLocation { line, column },
321 code: "ALOPEX-F001",
322 },
323 }
324 }
325}
326
327impl From<StorageError> for SqlError {
328 fn from(value: StorageError) -> Self {
329 match value {
330 StorageError::TransactionConflict => Self::Storage {
331 message: "transaction conflict".to_string(),
332 code: "ALOPEX-S001",
333 source: Some(alopex_core::Error::TxnConflict),
334 },
335 StorageError::TransactionReadOnly => Self::Storage {
336 message: "transaction is read-only".to_string(),
337 code: "ALOPEX-S003",
338 source: Some(alopex_core::Error::TxnReadOnly),
339 },
340 StorageError::TransactionClosed => Self::Storage {
341 message: "transaction is closed".to_string(),
342 code: "ALOPEX-S002",
343 source: Some(alopex_core::Error::TxnClosed),
344 },
345 StorageError::KvError(core_error) => Self::from(core_error),
346 other => Self::Storage {
347 message: other.to_string(),
348 code: "ALOPEX-S999",
349 source: None,
350 },
351 }
352 }
353}
354
355impl From<CatalogError> for SqlError {
356 fn from(value: CatalogError) -> Self {
357 match value {
358 CatalogError::Kv(core_error) => Self::from(StorageError::from(core_error)),
359 other => Self::Catalog {
360 message: format!("catalog persistence error: {other}"),
361 location: ErrorLocation::default(),
362 code: "ALOPEX-C999",
363 },
364 }
365 }
366}
367
368impl From<ExecutorError> for SqlError {
369 fn from(value: ExecutorError) -> Self {
370 match value {
371 ExecutorError::Planner(planner_error) => Self::from(planner_error),
372 ExecutorError::Core(core_error) => Self::from(core_error),
373 ExecutorError::Storage(storage_error) => Self::from(storage_error),
374 ExecutorError::TransactionConflict => Self::Execution {
375 message: "transaction conflict".to_string(),
376 code: "ALOPEX-E001",
377 },
378 ExecutorError::ReadOnlyTransaction { operation } => Self::Execution {
379 message: format!("read-only transaction: cannot execute {operation}"),
380 code: "ALOPEX-E002",
381 },
382 ExecutorError::TableNotFound(name) => Self::Catalog {
383 message: format!("table '{name}' not found"),
384 location: ErrorLocation::default(),
385 code: "ALOPEX-C001",
386 },
387 ExecutorError::TableAlreadyExists(name) => Self::Catalog {
388 message: format!("table '{name}' already exists"),
389 location: ErrorLocation::default(),
390 code: "ALOPEX-C002",
391 },
392 ExecutorError::IndexNotFound(name) => Self::Catalog {
393 message: format!("index '{name}' not found"),
394 location: ErrorLocation::default(),
395 code: "ALOPEX-C006",
396 },
397 ExecutorError::IndexAlreadyExists(name) => Self::Catalog {
398 message: format!("index '{name}' already exists"),
399 location: ErrorLocation::default(),
400 code: "ALOPEX-C005",
401 },
402 ExecutorError::ColumnNotFound(column) => Self::Catalog {
403 message: format!("column '{column}' not found"),
404 location: ErrorLocation::default(),
405 code: "ALOPEX-C003",
406 },
407 other => Self::Execution {
408 message: other.to_string(),
409 code: "ALOPEX-E999",
410 },
411 }
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn from_parser_error_preserves_location() {
421 let parser_error = ParserError::UnexpectedToken {
422 line: 12,
423 column: 34,
424 expected: "SELECT".into(),
425 found: "SELEC".into(),
426 };
427
428 let unified: SqlError = parser_error.into();
429 assert_eq!(unified.code(), "ALOPEX-P001");
430 assert_eq!(
431 unified.location(),
432 ErrorLocation {
433 line: 12,
434 column: 34
435 }
436 );
437 }
438
439 #[test]
440 fn from_planner_error_preserves_code() {
441 let planner_error = PlannerError::TableNotFound {
442 name: "users".into(),
443 line: 1,
444 column: 8,
445 };
446
447 let unified: SqlError = planner_error.into();
448 assert_eq!(unified.code(), "ALOPEX-C001");
449 assert_eq!(unified.location(), ErrorLocation { line: 1, column: 8 });
450 }
451
452 #[test]
453 fn message_with_location_format() {
454 let parser_error = ParserError::InvalidNumber {
455 line: 3,
456 column: 7,
457 value: "12x".into(),
458 };
459
460 let unified: SqlError = parser_error.into();
461 assert_eq!(
462 unified.message_with_location(),
463 "error[ALOPEX-P004]: invalid number literal '12x' at line 3, column 7"
464 );
465 }
466
467 #[test]
468 fn from_executor_core_error_maps_to_storage_and_preserves_source() {
469 let unified: SqlError = ExecutorError::Core(alopex_core::Error::TxnConflict).into();
470 assert_eq!(unified.code(), "ALOPEX-S001");
471 assert!(unified.source().is_some());
472 assert_eq!(
473 unified.message_with_location(),
474 "error[ALOPEX-S001]: storage error: transaction conflict"
475 );
476 }
477
478 #[test]
479 fn from_executor_core_readonly_maps_to_storage_and_preserves_source() {
480 let unified: SqlError = ExecutorError::Core(alopex_core::Error::TxnReadOnly).into();
481 assert_eq!(unified.code(), "ALOPEX-S003");
482 assert!(unified.source().is_some());
483 assert_eq!(
484 unified.message_with_location(),
485 "error[ALOPEX-S003]: storage error: transaction is read-only"
486 );
487 }
488
489 #[test]
490 fn from_executor_readonly_transaction_maps_to_execution_code() {
491 let unified: SqlError = ExecutorError::ReadOnlyTransaction {
492 operation: "INSERT".to_string(),
493 }
494 .into();
495 assert_eq!(unified.code(), "ALOPEX-E002");
496 assert_eq!(
497 unified.message_with_location(),
498 "error[ALOPEX-E002]: read-only transaction: cannot execute INSERT"
499 );
500 }
501}