1use std::error::Error as StdError;
7use std::fmt;
8
9use crate::catalog::CatalogError;
10use crate::error::ParserError;
11use crate::executor::{EvaluationError, 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 ParserError::InternalParserDefect { message } => Self::Parse {
213 message: format!(
214 "internal parser defect (this is a parser bug, not invalid SQL): {message}"
215 ),
216 location: ErrorLocation::default(),
217 code: "ALOPEX-P007",
218 },
219 }
220 }
221}
222
223impl From<PlannerError> for SqlError {
224 fn from(value: PlannerError) -> Self {
225 match value {
226 PlannerError::TableNotFound { name, line, column } => Self::Catalog {
227 message: format!("table '{name}' not found"),
228 location: ErrorLocation { line, column },
229 code: "ALOPEX-C001",
230 },
231 PlannerError::TableAlreadyExists { name } => Self::Catalog {
232 message: format!("table '{name}' already exists"),
233 location: ErrorLocation::default(),
234 code: "ALOPEX-C002",
235 },
236 PlannerError::ColumnNotFound {
237 column,
238 table,
239 line,
240 col,
241 } => Self::Catalog {
242 message: format!("column '{column}' not found in table '{table}'"),
243 location: ErrorLocation { line, column: col },
244 code: "ALOPEX-C003",
245 },
246 PlannerError::AmbiguousColumn {
247 column,
248 tables,
249 line,
250 col,
251 } => Self::Catalog {
252 message: format!("ambiguous column '{column}' found in tables: {tables:?}"),
253 location: ErrorLocation { line, column: col },
254 code: "ALOPEX-C004",
255 },
256 PlannerError::IndexAlreadyExists { name } => Self::Catalog {
257 message: format!("index '{name}' already exists"),
258 location: ErrorLocation::default(),
259 code: "ALOPEX-C005",
260 },
261 PlannerError::IndexNotFound { name } => Self::Catalog {
262 message: format!("index '{name}' not found"),
263 location: ErrorLocation::default(),
264 code: "ALOPEX-C006",
265 },
266 PlannerError::TypeMismatch {
267 expected,
268 found,
269 line,
270 column,
271 } => Self::Plan {
272 message: format!("type mismatch: expected {expected}, found {found}"),
273 location: ErrorLocation { line, column },
274 code: "ALOPEX-T001",
275 },
276 PlannerError::InvalidOperator {
277 op,
278 type_name,
279 line,
280 column,
281 } => Self::Plan {
282 message: format!("invalid operator '{op}' for type '{type_name}'"),
283 location: ErrorLocation { line, column },
284 code: "ALOPEX-T002",
285 },
286 PlannerError::NullConstraintViolation { column, line, col } => Self::Plan {
287 message: format!("null constraint violation for column '{column}'"),
288 location: ErrorLocation { line, column: col },
289 code: "ALOPEX-T003",
290 },
291 PlannerError::VectorDimensionMismatch {
292 expected,
293 found,
294 line,
295 column,
296 } => Self::Plan {
297 message: format!("vector dimension mismatch: expected {expected}, found {found}"),
298 location: ErrorLocation { line, column },
299 code: "ALOPEX-T004",
300 },
301 PlannerError::InvalidMetric {
302 value,
303 line,
304 column,
305 } => Self::Plan {
306 message: format!("invalid metric '{value}' (valid: cosine, l2, inner)"),
307 location: ErrorLocation { line, column },
308 code: "ALOPEX-T005",
309 },
310 PlannerError::ColumnValueCountMismatch {
311 columns,
312 values,
313 line,
314 column,
315 } => Self::Plan {
316 message: format!("column count ({columns}) does not match value count ({values})"),
317 location: ErrorLocation { line, column },
318 code: "ALOPEX-T006",
319 },
320 PlannerError::UnsupportedFeature {
321 feature,
322 version,
323 line,
324 column,
325 } => Self::Plan {
326 message: format!("feature '{feature}' is not supported (expected in {version})"),
327 location: ErrorLocation { line, column },
328 code: "ALOPEX-F001",
329 },
330 PlannerError::InvalidExpression { message } => Self::Plan {
331 message,
332 location: ErrorLocation::default(),
333 code: "ALOPEX-T007",
334 },
335 PlannerError::SetOperationColumnCountMismatch {
336 left,
337 right,
338 line,
339 column,
340 } => Self::Plan {
341 message: format!("set operation column count mismatch: left {left}, right {right}"),
342 location: ErrorLocation { line, column },
343 code: "ALOPEX-T008",
344 },
345 PlannerError::CteColumnCountMismatch {
346 cte,
347 declared,
348 actual,
349 line,
350 column,
351 } => Self::Plan {
352 message: format!(
353 "common table expression '{cte}' declares {declared} column names but its query returns {actual} columns"
354 ),
355 location: ErrorLocation { line, column },
356 code: "ALOPEX-T009",
357 },
358 PlannerError::DuplicateCteColumn {
359 cte,
360 name,
361 line,
362 column,
363 } => Self::Plan {
364 message: format!(
365 "common table expression '{cte}' declares column '{name}' more than once"
366 ),
367 location: ErrorLocation { line, column },
368 code: "ALOPEX-T010",
369 },
370 PlannerError::ValuesColumnCountMismatch {
371 row,
372 expected,
373 actual,
374 line,
375 column,
376 } => Self::Plan {
377 message: format!("VALUES row {row} has {actual} columns but row 1 has {expected}"),
378 location: ErrorLocation { line, column },
379 code: "ALOPEX-T011",
380 },
381 PlannerError::TableAliasColumnCountMismatch {
382 alias,
383 declared,
384 actual,
385 line,
386 column,
387 } => Self::Plan {
388 message: format!(
389 "relation alias '{alias}' declares {declared} column names but the relation has {actual} columns"
390 ),
391 location: ErrorLocation { line, column },
392 code: "ALOPEX-T012",
393 },
394 PlannerError::RowArityMismatch {
395 expected,
396 actual,
397 line,
398 column,
399 } => Self::Plan {
400 message: format!(
401 "row value has {actual} fields but its comparison operand has {expected}"
402 ),
403 location: ErrorLocation { line, column },
404 code: "ALOPEX-T013",
405 },
406 PlannerError::UnknownTableFunction { name, line, column } => Self::Plan {
407 message: format!("table function '{name}' does not exist"),
408 location: ErrorLocation { line, column },
409 code: "ALOPEX-C007",
410 },
411 PlannerError::LateralJoinTypeUnsupported {
412 join_type,
413 line,
414 column,
415 } => Self::Plan {
416 message: format!("{join_type} JOIN cannot have a LATERAL right side"),
417 location: ErrorLocation { line, column },
418 code: "ALOPEX-T015",
419 },
420 PlannerError::DistinctOnOrderByMismatch { line, column } => Self::Plan {
421 message: "SELECT DISTINCT ON expressions must match initial ORDER BY expressions"
422 .to_string(),
423 location: ErrorLocation { line, column },
424 code: "ALOPEX-T014",
425 },
426 PlannerError::InvalidPragma { name, reason } => Self::Plan {
427 message: format!("invalid PRAGMA '{name}': {reason}"),
428 location: ErrorLocation::default(),
429 code: "ALOPEX-F002",
430 },
431 }
432 }
433}
434
435impl From<StorageError> for SqlError {
436 fn from(value: StorageError) -> Self {
437 match value {
438 StorageError::TransactionConflict => Self::Storage {
439 message: "transaction conflict".to_string(),
440 code: "ALOPEX-S001",
441 source: Some(alopex_core::Error::TxnConflict),
442 },
443 StorageError::TransactionReadOnly => Self::Storage {
444 message: "transaction is read-only".to_string(),
445 code: "ALOPEX-S003",
446 source: Some(alopex_core::Error::TxnReadOnly),
447 },
448 StorageError::TransactionClosed => Self::Storage {
449 message: "transaction is closed".to_string(),
450 code: "ALOPEX-S002",
451 source: Some(alopex_core::Error::TxnClosed),
452 },
453 StorageError::KvError(core_error) => Self::from(core_error),
454 other => Self::Storage {
455 message: other.to_string(),
456 code: "ALOPEX-S999",
457 source: None,
458 },
459 }
460 }
461}
462
463impl From<CatalogError> for SqlError {
464 fn from(value: CatalogError) -> Self {
465 match value {
466 CatalogError::Kv(core_error) => Self::from(StorageError::from(core_error)),
467 other => Self::Catalog {
468 message: format!("catalog persistence error: {other}"),
469 location: ErrorLocation::default(),
470 code: "ALOPEX-C999",
471 },
472 }
473 }
474}
475
476impl From<ExecutorError> for SqlError {
477 fn from(value: ExecutorError) -> Self {
478 match value {
479 ExecutorError::Planner(planner_error) => Self::from(planner_error),
480 ExecutorError::Core(core_error) => Self::from(core_error),
481 ExecutorError::Storage(storage_error) => Self::from(storage_error),
482 ExecutorError::TransactionConflict => Self::Execution {
483 message: "transaction conflict".to_string(),
484 code: "ALOPEX-E001",
485 },
486 ExecutorError::ReadOnlyTransaction { operation } => Self::Execution {
487 message: format!("read-only transaction: cannot execute {operation}"),
488 code: "ALOPEX-E002",
489 },
490 ExecutorError::ResourceExhausted { message } => Self::Execution {
491 message: format!("resource exhausted: {message}"),
492 code: "ALOPEX-E003",
493 },
494 ExecutorError::Evaluation(EvaluationError::CastFailed {
495 source_type,
496 target,
497 reason,
498 }) => Self::Execution {
499 message: format!("cannot cast {source_type} to {target}: {reason}"),
500 code: "ALOPEX-E004",
501 },
502 ExecutorError::TableNotFound(name) => Self::Catalog {
503 message: format!("table '{name}' not found"),
504 location: ErrorLocation::default(),
505 code: "ALOPEX-C001",
506 },
507 ExecutorError::TableAlreadyExists(name) => Self::Catalog {
508 message: format!("table '{name}' already exists"),
509 location: ErrorLocation::default(),
510 code: "ALOPEX-C002",
511 },
512 ExecutorError::IndexNotFound(name) => Self::Catalog {
513 message: format!("index '{name}' not found"),
514 location: ErrorLocation::default(),
515 code: "ALOPEX-C006",
516 },
517 ExecutorError::IndexAlreadyExists(name) => Self::Catalog {
518 message: format!("index '{name}' already exists"),
519 location: ErrorLocation::default(),
520 code: "ALOPEX-C005",
521 },
522 ExecutorError::ColumnNotFound(column) => Self::Catalog {
523 message: format!("column '{column}' not found"),
524 location: ErrorLocation::default(),
525 code: "ALOPEX-C003",
526 },
527 other => Self::Execution {
528 message: other.to_string(),
529 code: "ALOPEX-E999",
530 },
531 }
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538
539 #[test]
540 fn from_parser_error_preserves_location() {
541 let parser_error = ParserError::UnexpectedToken {
542 line: 12,
543 column: 34,
544 expected: "SELECT".into(),
545 found: "SELEC".into(),
546 };
547
548 let unified: SqlError = parser_error.into();
549 assert_eq!(unified.code(), "ALOPEX-P001");
550 assert_eq!(
551 unified.location(),
552 ErrorLocation {
553 line: 12,
554 column: 34
555 }
556 );
557 }
558
559 #[test]
560 fn from_planner_error_preserves_code() {
561 let planner_error = PlannerError::TableNotFound {
562 name: "users".into(),
563 line: 1,
564 column: 8,
565 };
566
567 let unified: SqlError = planner_error.into();
568 assert_eq!(unified.code(), "ALOPEX-C001");
569 assert_eq!(unified.location(), ErrorLocation { line: 1, column: 8 });
570 }
571
572 #[test]
573 fn cte_column_errors_preserve_public_codes_and_locations() {
574 let count_mismatch: SqlError = PlannerError::CteColumnCountMismatch {
575 cte: "items".into(),
576 declared: 2,
577 actual: 1,
578 line: 3,
579 column: 6,
580 }
581 .into();
582 assert_eq!(count_mismatch.code(), "ALOPEX-T009");
583 assert_eq!(
584 count_mismatch.location(),
585 ErrorLocation { line: 3, column: 6 }
586 );
587
588 let duplicate: SqlError = PlannerError::DuplicateCteColumn {
589 cte: "items".into(),
590 name: "identifier".into(),
591 line: 4,
592 column: 7,
593 }
594 .into();
595 assert_eq!(duplicate.code(), "ALOPEX-T010");
596 assert_eq!(duplicate.location(), ErrorLocation { line: 4, column: 7 });
597 }
598
599 #[test]
600 fn values_shape_errors_preserve_public_codes_and_locations() {
601 let row_width: SqlError = PlannerError::ValuesColumnCountMismatch {
602 row: 2,
603 expected: 2,
604 actual: 1,
605 line: 5,
606 column: 8,
607 }
608 .into();
609 assert_eq!(row_width.code(), "ALOPEX-T011");
610 assert_eq!(row_width.location(), ErrorLocation { line: 5, column: 8 });
611
612 let alias_width: SqlError = PlannerError::TableAliasColumnCountMismatch {
613 alias: "items".into(),
614 declared: 1,
615 actual: 2,
616 line: 6,
617 column: 9,
618 }
619 .into();
620 assert_eq!(alias_width.code(), "ALOPEX-T012");
621 assert_eq!(alias_width.location(), ErrorLocation { line: 6, column: 9 });
622
623 let row_width: SqlError = PlannerError::RowArityMismatch {
624 expected: 2,
625 actual: 3,
626 line: 7,
627 column: 10,
628 }
629 .into();
630 assert_eq!(row_width.code(), "ALOPEX-T013");
631 assert_eq!(
632 row_width.location(),
633 ErrorLocation {
634 line: 7,
635 column: 10
636 }
637 );
638 }
639
640 #[test]
641 fn message_with_location_format() {
642 let parser_error = ParserError::InvalidNumber {
643 line: 3,
644 column: 7,
645 value: "12x".into(),
646 };
647
648 let unified: SqlError = parser_error.into();
649 assert_eq!(
650 unified.message_with_location(),
651 "error[ALOPEX-P004]: invalid number literal '12x' at line 3, column 7"
652 );
653 }
654
655 #[test]
656 fn from_executor_core_error_maps_to_storage_and_preserves_source() {
657 let unified: SqlError = ExecutorError::Core(alopex_core::Error::TxnConflict).into();
658 assert_eq!(unified.code(), "ALOPEX-S001");
659 assert!(unified.source().is_some());
660 assert_eq!(
661 unified.message_with_location(),
662 "error[ALOPEX-S001]: storage error: transaction conflict"
663 );
664 }
665
666 #[test]
667 fn from_executor_core_readonly_maps_to_storage_and_preserves_source() {
668 let unified: SqlError = ExecutorError::Core(alopex_core::Error::TxnReadOnly).into();
669 assert_eq!(unified.code(), "ALOPEX-S003");
670 assert!(unified.source().is_some());
671 assert_eq!(
672 unified.message_with_location(),
673 "error[ALOPEX-S003]: storage error: transaction is read-only"
674 );
675 }
676
677 #[test]
678 fn from_executor_readonly_transaction_maps_to_execution_code() {
679 let unified: SqlError = ExecutorError::ReadOnlyTransaction {
680 operation: "INSERT".to_string(),
681 }
682 .into();
683 assert_eq!(unified.code(), "ALOPEX-E002");
684 assert_eq!(
685 unified.message_with_location(),
686 "error[ALOPEX-E002]: read-only transaction: cannot execute INSERT"
687 );
688 }
689
690 #[test]
691 fn from_executor_resource_exhausted_maps_to_stable_execution_code() {
692 let unified: SqlError = ExecutorError::ResourceExhausted {
693 message: "recursive CTE 'numbers' reached row limit 100000".to_string(),
694 }
695 .into();
696
697 assert_eq!(unified.code(), "ALOPEX-E003");
698 assert_eq!(
699 unified.message_with_location(),
700 "error[ALOPEX-E003]: resource exhausted: recursive CTE 'numbers' reached row limit 100000"
701 );
702 }
703
704 #[test]
705 fn from_cast_failure_preserves_stable_e004_without_internal_vocabulary() {
706 let unified = SqlError::from(ExecutorError::Evaluation(EvaluationError::CastFailed {
707 source_type: "Text".to_string(),
708 target: "INTEGER".to_string(),
709 reason: "invalid integer text".to_string(),
710 }));
711
712 assert_eq!(unified.code(), "ALOPEX-E004");
713 let rendered = unified.to_string();
714 assert!(rendered.contains("cannot cast Text to INTEGER"));
715 assert!(!rendered.contains("TypedExpr"));
716 assert!(!rendered.contains("MessagePack"));
717 }
718}