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::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        }
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}