Skip to main content

alopex_sql/
unified_error.rs

1//! SQL 実行パイプライン(Parse/Plan/Execute)を横断する統一エラー型。
2//!
3//! 公開 API としては「安定した形」を維持するため、内部エラー型(Parser/Planner/Executor)を
4//! そのまま公開せず、`message / code / location` を持つフィールド形式のエラーを提供する。
5
6use 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/// エラー位置情報(1-based、未知の場合は 0)。
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub struct ErrorLocation {
18    pub line: u64,
19    pub column: u64,
20}
21
22impl ErrorLocation {
23    /// 位置情報が有効か判定する。
24    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/// 統一 SQL エラー型(公開 API)。
36///
37/// # Examples
38///
39/// ```
40/// use alopex_sql::SqlError;
41/// use alopex_sql::StorageError;
42///
43/// let err = SqlError::from(StorageError::TransactionConflict);
44/// assert_eq!(err.code(), "ALOPEX-S001");
45/// ```
46#[derive(Debug)]
47pub enum SqlError {
48    /// パースエラー。
49    Parse {
50        message: String,
51        location: ErrorLocation,
52        code: &'static str,
53    },
54
55    /// プランニングエラー(型エラー等)。
56    Plan {
57        message: String,
58        location: ErrorLocation,
59        code: &'static str,
60    },
61
62    /// 実行エラー。
63    Execution { message: String, code: &'static str },
64
65    /// ストレージエラー(REQ-4-3: `source` を保持してエラーチェーンを維持)。
66    Storage {
67        message: String,
68        code: &'static str,
69        source: Option<alopex_core::Error>,
70    },
71
72    /// カタログエラー(テーブル/インデックス等の参照・整合性)。
73    Catalog {
74        message: String,
75        location: ErrorLocation,
76        code: &'static str,
77    },
78}
79
80impl SqlError {
81    /// エラーコード(例: `ALOPEX-C001`)。
82    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    /// ユーザー向けメッセージ(位置情報は含めない)。
93    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    /// 位置情報(未知の場合は `{ line: 0, column: 0 }`)。
104    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    /// span 情報付きメッセージを生成する(位置情報がない場合は位置部分を省略)。
114    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}