Skip to main content

fraiseql_error/
core_error.rs

1//! Core error types for FraiseQL operations.
2//!
3//! This module provides the primary error enum `FraiseQLError` used throughout
4//! the FraiseQL compilation and execution pipeline.
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9/// Result type alias for FraiseQL operations.
10pub type Result<T> = std::result::Result<T, FraiseQLError>;
11
12/// Main error type for FraiseQL operations.
13///
14/// All errors in the core library are converted to this type.
15/// Language bindings convert this to their native error types.
16///
17/// # Error Categories
18///
19/// Errors are organized by domain:
20///
21/// ## GraphQL Errors
22/// - `Parse` — Malformed GraphQL syntax
23/// - `Validation` — Schema validation failures
24/// - `UnknownField` — Field doesn't exist on type
25/// - `UnknownType` — Type doesn't exist in schema
26///
27/// ## Database Errors
28/// - `Database` — PostgreSQL/MySQL/SQLite errors (includes SQL state code)
29/// - `ConnectionPool` — Connection pool exhausted or unavailable
30/// - `Timeout` — Query exceeded configured timeout
31/// - `Cancelled` — Query was cancelled by caller
32///
33/// ## Authorization/Security Errors
34/// - `Authorization` — User lacks permission for operation
35/// - `Authentication` — Invalid/expired JWT token
36/// - `RateLimited` — Too many requests (includes retry-after)
37///
38/// ## Resource Errors
39/// - `NotFound` — Resource doesn't exist (404)
40/// - `Conflict` — Operation would violate constraints (409)
41///
42/// ## Configuration Errors
43/// - `Configuration` — Invalid setup/configuration
44/// - `Unsupported` — Operation not supported by current database backend
45///
46/// ## Internal Errors
47/// - `Internal` — Unexpected internal failures
48///
49/// # Stability
50///
51/// This enum is marked `#[non_exhaustive]` to allow adding new error variants
52/// in future minor versions without breaking backward compatibility.
53///
54/// External `match` expressions must include a wildcard `_` arm:
55///
56/// ```rust
57/// use fraiseql_error::FraiseQLError;
58///
59/// fn describe(e: &FraiseQLError) -> &'static str {
60///     match e {
61///         FraiseQLError::Parse { .. } => "parse error",
62///         FraiseQLError::Validation { .. } => "validation error",
63///         _ => "other error", // required: FraiseQLError is #[non_exhaustive]
64///     }
65/// }
66/// ```
67///
68/// The following would **not** compile (missing wildcard arm):
69///
70/// ```compile_fail
71/// use fraiseql_error::FraiseQLError;
72///
73/// fn describe(e: &FraiseQLError) -> &'static str {
74///     match e {
75///         FraiseQLError::Parse { .. } => "parse",
76///         FraiseQLError::Validation { .. } => "validation",
77///         FraiseQLError::Database { .. } => "database",
78///         FraiseQLError::Network { .. } => "network",
79///         FraiseQLError::Authorization { .. } => "authorization",
80///         FraiseQLError::NotFound { .. } => "not found",
81///         FraiseQLError::Conflict { .. } => "conflict",
82///         FraiseQLError::Configuration { .. } => "configuration",
83///         FraiseQLError::Unsupported { .. } => "unsupported",
84///         FraiseQLError::Internal { .. } => "internal",
85///         FraiseQLError::UnknownField { .. } => "unknown field",
86///         FraiseQLError::UnknownType { .. } => "unknown type",
87///         FraiseQLError::FieldExclusion { .. } => "field exclusion",
88///         FraiseQLError::TypeMismatch { .. } => "type mismatch",
89///         FraiseQLError::RateLimitExceeded { .. } => "rate limit",
90///         FraiseQLError::Forbidden { .. } => "forbidden",
91///     }
92/// }
93/// ```
94#[derive(Error, Debug)]
95#[non_exhaustive]
96pub enum FraiseQLError {
97    // ========================================================================
98    // GraphQL Errors
99    // ========================================================================
100    /// GraphQL parsing error.
101    #[error("Parse error at {location}: {message}")]
102    Parse {
103        /// Error message describing the parse failure.
104        message:  String,
105        /// Location in the query where the error occurred.
106        location: String,
107    },
108
109    /// GraphQL validation error.
110    #[error("Validation error: {message}")]
111    Validation {
112        /// Error message describing the validation failure.
113        message: String,
114        /// Path to the field with the error (e.g., "user.posts.0.title").
115        path:    Option<String>,
116    },
117
118    /// Unknown field error.
119    #[error("Unknown field '{field}' on type '{type_name}'")]
120    UnknownField {
121        /// The field name that was not found.
122        field:     String,
123        /// The type on which the field was queried.
124        type_name: String,
125    },
126
127    /// Unknown type error.
128    #[error("Unknown type '{type_name}'")]
129    UnknownType {
130        /// The type name that was not found.
131        type_name: String,
132    },
133
134    // ========================================================================
135    // Database Errors
136    // ========================================================================
137    /// Database operation error.
138    #[error("Database error: {message}")]
139    Database {
140        /// Error message from the database.
141        message:   String,
142        /// SQL state code if available (e.g., "23505" for unique violation).
143        sql_state: Option<String>,
144    },
145
146    /// Connection pool error.
147    #[error("Connection pool error: {message}")]
148    ConnectionPool {
149        /// Error message.
150        message: String,
151    },
152
153    /// Query timeout error.
154    #[error("Query timeout after {timeout_ms}ms")]
155    Timeout {
156        /// Timeout duration in milliseconds.
157        timeout_ms: u64,
158        /// The query that timed out (truncated if too long).
159        query:      Option<String>,
160    },
161
162    /// Query cancellation error.
163    #[error("Query cancelled: {reason}")]
164    Cancelled {
165        /// Query identifier for tracking/logging.
166        query_id: String,
167        /// Reason for cancellation.
168        reason:   String,
169    },
170
171    // ========================================================================
172    // Authorization Errors
173    // ========================================================================
174    /// Authorization error.
175    #[error("Authorization error: {message}")]
176    Authorization {
177        /// Error message.
178        message:  String,
179        /// The action that was denied.
180        action:   Option<String>,
181        /// The resource that was being accessed.
182        resource: Option<String>,
183    },
184
185    /// Authentication error.
186    #[error("Authentication error: {message}")]
187    Authentication {
188        /// Error message.
189        message: String,
190    },
191
192    /// Rate limiting error.
193    #[error("Rate limit exceeded: {message}")]
194    RateLimited {
195        /// Error message.
196        message:          String,
197        /// Number of seconds to wait before retrying.
198        retry_after_secs: u64,
199    },
200
201    // ========================================================================
202    // Resource Errors
203    // ========================================================================
204    /// Resource not found error.
205    #[error("{resource_type} not found: {identifier}")]
206    NotFound {
207        /// Type of resource (e.g., "User", "Post").
208        resource_type: String,
209        /// Identifier that was looked up.
210        identifier:    String,
211    },
212
213    /// Conflict error.
214    #[error("Conflict: {message}")]
215    Conflict {
216        /// Error message.
217        message: String,
218    },
219
220    // ========================================================================
221    // Configuration Errors
222    // ========================================================================
223    /// Configuration error.
224    #[error("Configuration error: {message}")]
225    Configuration {
226        /// Error message.
227        message: String,
228    },
229
230    /// Unsupported operation error.
231    #[error("Unsupported operation: {message}")]
232    Unsupported {
233        /// Error message describing what is not supported.
234        message: String,
235    },
236
237    // ========================================================================
238    // Internal Errors
239    // ========================================================================
240    /// Internal error.
241    #[error("Internal error: {message}")]
242    Internal {
243        /// Error message.
244        message: String,
245        /// Optional source error for debugging.
246        #[source]
247        source:  Option<Box<dyn std::error::Error + Send + Sync>>,
248    },
249}
250
251impl FraiseQLError {
252    /// Create a parse error.
253    #[must_use]
254    pub fn parse(message: impl Into<String>) -> Self {
255        Self::Parse {
256            message:  message.into(),
257            location: "unknown".to_string(),
258        }
259    }
260
261    /// Create a parse error with location.
262    #[must_use]
263    pub fn parse_at(message: impl Into<String>, location: impl Into<String>) -> Self {
264        Self::Parse {
265            message:  message.into(),
266            location: location.into(),
267        }
268    }
269
270    /// Create a validation error.
271    #[must_use]
272    pub fn validation(message: impl Into<String>) -> Self {
273        Self::Validation {
274            message: message.into(),
275            path:    None,
276        }
277    }
278
279    /// Create a validation error with path.
280    #[must_use]
281    pub fn validation_at(message: impl Into<String>, path: impl Into<String>) -> Self {
282        Self::Validation {
283            message: message.into(),
284            path:    Some(path.into()),
285        }
286    }
287
288    /// Create a database error.
289    #[must_use]
290    pub fn database(message: impl Into<String>) -> Self {
291        Self::Database {
292            message:   message.into(),
293            sql_state: None,
294        }
295    }
296
297    /// Create an authorization error.
298    #[must_use]
299    pub fn unauthorized(message: impl Into<String>) -> Self {
300        Self::Authorization {
301            message:  message.into(),
302            action:   None,
303            resource: None,
304        }
305    }
306
307    /// Create a not found error.
308    #[must_use]
309    pub fn not_found(resource_type: impl Into<String>, identifier: impl Into<String>) -> Self {
310        Self::NotFound {
311            resource_type: resource_type.into(),
312            identifier:    identifier.into(),
313        }
314    }
315
316    /// Create a configuration error.
317    #[must_use]
318    pub fn config(message: impl Into<String>) -> Self {
319        Self::Configuration {
320            message: message.into(),
321        }
322    }
323
324    /// Create an internal error.
325    #[must_use]
326    pub fn internal(message: impl Into<String>) -> Self {
327        Self::Internal {
328            message: message.into(),
329            source:  None,
330        }
331    }
332
333    /// Create a cancellation error.
334    #[must_use]
335    pub fn cancelled(query_id: impl Into<String>, reason: impl Into<String>) -> Self {
336        Self::Cancelled {
337            query_id: query_id.into(),
338            reason:   reason.into(),
339        }
340    }
341
342    /// Check if this is a client error (4xx equivalent).
343    #[must_use]
344    pub const fn is_client_error(&self) -> bool {
345        matches!(
346            self,
347            Self::Parse { .. }
348                | Self::Validation { .. }
349                | Self::UnknownField { .. }
350                | Self::UnknownType { .. }
351                | Self::Authorization { .. }
352                | Self::Authentication { .. }
353                | Self::NotFound { .. }
354                | Self::Conflict { .. }
355                | Self::RateLimited { .. }
356        )
357    }
358
359    /// Check if this is a server error (5xx equivalent).
360    #[must_use]
361    pub const fn is_server_error(&self) -> bool {
362        matches!(
363            self,
364            Self::Database { .. }
365                | Self::ConnectionPool { .. }
366                | Self::Timeout { .. }
367                | Self::Cancelled { .. }
368                | Self::Configuration { .. }
369                | Self::Unsupported { .. }
370                | Self::Internal { .. }
371        )
372    }
373
374    /// Check if this error is retryable.
375    #[must_use]
376    pub const fn is_retryable(&self) -> bool {
377        matches!(
378            self,
379            Self::ConnectionPool { .. } | Self::Timeout { .. } | Self::Cancelled { .. }
380        )
381    }
382
383    /// Get HTTP status code equivalent.
384    #[must_use]
385    pub const fn status_code(&self) -> u16 {
386        match self {
387            Self::Parse { .. }
388            | Self::Validation { .. }
389            | Self::UnknownField { .. }
390            | Self::UnknownType { .. } => 400,
391            Self::Authentication { .. } => 401,
392            Self::Authorization { .. } => 403,
393            Self::NotFound { .. } => 404,
394            Self::Conflict { .. } => 409,
395            Self::RateLimited { .. } => 429,
396            Self::Timeout { .. } | Self::Cancelled { .. } => 408,
397            Self::Database { .. }
398            | Self::ConnectionPool { .. }
399            | Self::Configuration { .. }
400            | Self::Internal { .. } => 500,
401            Self::Unsupported { .. } => 501,
402        }
403    }
404
405    /// Get error code for GraphQL response.
406    #[must_use]
407    pub const fn error_code(&self) -> &'static str {
408        match self {
409            Self::Parse { .. } => "GRAPHQL_PARSE_FAILED",
410            Self::Validation { .. } => "GRAPHQL_VALIDATION_FAILED",
411            Self::UnknownField { .. } => "UNKNOWN_FIELD",
412            Self::UnknownType { .. } => "UNKNOWN_TYPE",
413            Self::Database { .. } => "DATABASE_ERROR",
414            Self::ConnectionPool { .. } => "CONNECTION_POOL_ERROR",
415            Self::Timeout { .. } => "TIMEOUT",
416            Self::Cancelled { .. } => "CANCELLED",
417            Self::Authorization { .. } => "FORBIDDEN",
418            Self::Authentication { .. } => "UNAUTHENTICATED",
419            Self::RateLimited { .. } => "RATE_LIMITED",
420            Self::NotFound { .. } => "NOT_FOUND",
421            Self::Conflict { .. } => "CONFLICT",
422            Self::Configuration { .. } => "CONFIGURATION_ERROR",
423            Self::Unsupported { .. } => "UNSUPPORTED_OPERATION",
424            Self::Internal { .. } => "INTERNAL_SERVER_ERROR",
425        }
426    }
427
428    /// Create an unknown field error with helpful suggestions.
429    #[must_use]
430    pub fn unknown_field_with_suggestion(
431        field: impl Into<String>,
432        type_name: impl Into<String>,
433        available_fields: &[&str],
434    ) -> Self {
435        let field = field.into();
436        let type_name = type_name.into();
437
438        let suggestion = available_fields
439            .iter()
440            .map(|f| (*f, Self::levenshtein_distance(&field, f)))
441            .filter(|(_, distance)| *distance <= 2)
442            .min_by_key(|(_, distance)| *distance)
443            .map(|(f, _)| f);
444
445        if let Some(suggested_field) = suggestion {
446            Self::UnknownField {
447                field: format!("{field} (did you mean '{suggested_field}'?)"),
448                type_name,
449            }
450        } else {
451            Self::UnknownField { field, type_name }
452        }
453    }
454
455    fn levenshtein_distance(s1: &str, s2: &str) -> usize {
456        let len1 = s1.chars().count();
457        let len2 = s2.chars().count();
458
459        if len1 == 0 {
460            return len2;
461        }
462        if len2 == 0 {
463            return len1;
464        }
465
466        let mut matrix = vec![vec![0; len2 + 1]; len1 + 1];
467
468        for (i, row) in matrix.iter_mut().enumerate() {
469            row[0] = i;
470        }
471        for (j, val) in matrix[0].iter_mut().enumerate() {
472            *val = j;
473        }
474
475        for (i, c1) in s1.chars().enumerate() {
476            for (j, c2) in s2.chars().enumerate() {
477                let cost = usize::from(c1 != c2);
478                matrix[i + 1][j + 1] = std::cmp::min(
479                    std::cmp::min(matrix[i][j + 1] + 1, matrix[i + 1][j] + 1),
480                    matrix[i][j] + cost,
481                );
482            }
483        }
484
485        matrix[len1][len2]
486    }
487
488    /// Create a database error from PostgreSQL error code.
489    #[must_use]
490    pub fn from_postgres_code(code: &str, message: impl Into<String>) -> Self {
491        let message = message.into();
492        match code {
493            "42P01" => Self::Database {
494                message: "The table or view you're querying doesn't exist. \
495                          Check that the schema is compiled and the database is initialized."
496                    .to_string(),
497                sql_state: Some(code.to_string()),
498            },
499            "42703" => Self::Database {
500                message: "A column referenced in the query doesn't exist in the table. \
501                          This may indicate the database schema is out of sync with the compiled schema."
502                    .to_string(),
503                sql_state: Some(code.to_string()),
504            },
505            "23505" => Self::Conflict {
506                message: "A unique constraint was violated. This value already exists in the database.".to_string(),
507            },
508            "23503" => Self::Conflict {
509                message: "A foreign key constraint was violated. The referenced record doesn't exist."
510                    .to_string(),
511            },
512            "23502" => Self::Conflict {
513                message: "A NOT NULL constraint was violated. The field cannot be empty.".to_string(),
514            },
515            "22P02" => Self::Validation {
516                message: "Invalid input value. The provided value doesn't match the expected data type.".to_string(),
517                path: None,
518            },
519            _ => Self::Database {
520                message,
521                sql_state: Some(code.to_string()),
522            },
523        }
524    }
525
526    /// Create a rate limit error with retry information.
527    #[must_use]
528    pub fn rate_limited_with_retry(retry_after_secs: u64) -> Self {
529        Self::RateLimited {
530            message: format!(
531                "Rate limit exceeded. Please try again in {retry_after_secs} seconds. \
532                 For permanent increases, contact support."
533            ),
534            retry_after_secs,
535        }
536    }
537
538    /// Create an authentication error with context.
539    #[must_use]
540    pub fn auth_error(reason: impl Into<String>) -> Self {
541        Self::Authentication {
542            message: reason.into(),
543        }
544    }
545}
546
547impl From<serde_json::Error> for FraiseQLError {
548    fn from(e: serde_json::Error) -> Self {
549        Self::Parse {
550            message:  e.to_string(),
551            location: format!("line {}, column {}", e.line(), e.column()),
552        }
553    }
554}
555
556impl From<std::io::Error> for FraiseQLError {
557    fn from(e: std::io::Error) -> Self {
558        Self::Internal {
559            message: format!("I/O error: {e}"),
560            source:  Some(Box::new(e)),
561        }
562    }
563}
564
565impl From<std::env::VarError> for FraiseQLError {
566    fn from(e: std::env::VarError) -> Self {
567        Self::Configuration {
568            message: format!("Environment variable error: {e}"),
569        }
570    }
571}
572
573/// Extension trait for adding context to errors.
574pub trait ErrorContext<T> {
575    /// Add context to an error.
576    ///
577    /// # Errors
578    ///
579    /// Returns `Err` if the original value was `Err`, wrapping it in an `Internal` error with the
580    /// given message.
581    fn context(self, message: impl Into<String>) -> Result<T>;
582
583    /// Add context lazily (only computed on error).
584    ///
585    /// # Errors
586    ///
587    /// Returns `Err` if the original value was `Err`, wrapping it in an `Internal` error with the
588    /// context message.
589    fn with_context<F, M>(self, f: F) -> Result<T>
590    where
591        F: FnOnce() -> M,
592        M: Into<String>;
593}
594
595impl<T, E: Into<FraiseQLError>> ErrorContext<T> for std::result::Result<T, E> {
596    fn context(self, message: impl Into<String>) -> Result<T> {
597        self.map_err(|e| {
598            let inner = e.into();
599            FraiseQLError::Internal {
600                message: format!("{}: {inner}", message.into()),
601                source:  None,
602            }
603        })
604    }
605
606    fn with_context<F, M>(self, f: F) -> Result<T>
607    where
608        F: FnOnce() -> M,
609        M: Into<String>,
610    {
611        self.map_err(|e| {
612            let inner = e.into();
613            FraiseQLError::Internal {
614                message: format!("{}: {inner}", f().into()),
615                source:  None,
616            }
617        })
618    }
619}
620
621/// A validation error for a specific field in an input object.
622#[derive(Debug, Clone, Serialize, Deserialize)]
623pub struct ValidationFieldError {
624    /// Path to the field that failed validation.
625    pub field:     String,
626    /// Type of validation rule that failed.
627    pub rule_type: String,
628    /// Human-readable error message.
629    pub message:   String,
630}
631
632impl ValidationFieldError {
633    /// Create a new validation field error.
634    #[must_use]
635    pub fn new(
636        field: impl Into<String>,
637        rule_type: impl Into<String>,
638        message: impl Into<String>,
639    ) -> Self {
640        Self {
641            field:     field.into(),
642            rule_type: rule_type.into(),
643            message:   message.into(),
644        }
645    }
646}
647
648impl std::fmt::Display for ValidationFieldError {
649    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
650        write!(f, "{} ({}): {}", self.field, self.rule_type, self.message)
651    }
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657
658    #[test]
659    fn test_parse_error() {
660        let err = FraiseQLError::parse("unexpected token");
661        assert!(err.is_client_error());
662        assert!(!err.is_server_error());
663        assert_eq!(err.status_code(), 400);
664        assert_eq!(err.error_code(), "GRAPHQL_PARSE_FAILED");
665    }
666
667    #[test]
668    fn test_database_error() {
669        let err = FraiseQLError::database("connection refused");
670        assert!(!err.is_client_error());
671        assert!(err.is_server_error());
672        assert_eq!(err.status_code(), 500);
673    }
674
675    #[test]
676    fn test_not_found_error() {
677        let err = FraiseQLError::not_found("User", "123");
678        assert!(err.is_client_error());
679        assert_eq!(err.status_code(), 404);
680        assert_eq!(err.to_string(), "User not found: 123");
681    }
682
683    #[test]
684    fn test_retryable_errors() {
685        assert!(
686            FraiseQLError::ConnectionPool {
687                message: "timeout".to_string(),
688            }
689            .is_retryable()
690        );
691        assert!(
692            FraiseQLError::Timeout {
693                timeout_ms: 5000,
694                query:      None,
695            }
696            .is_retryable()
697        );
698        assert!(!FraiseQLError::parse("bad query").is_retryable());
699    }
700
701    #[test]
702    fn test_unsupported_is_501() {
703        let err = FraiseQLError::Unsupported {
704            message: "execute_function_call not supported on SQLite".to_string(),
705        };
706        assert_eq!(err.status_code(), 501);
707        assert!(err.is_server_error());
708        assert_eq!(err.error_code(), "UNSUPPORTED_OPERATION");
709    }
710
711    #[test]
712    fn test_from_serde_error() {
713        let json_err = serde_json::from_str::<serde_json::Value>("not json")
714            .expect_err("'not json' must fail to parse");
715        let err: FraiseQLError = json_err.into();
716        assert!(matches!(err, FraiseQLError::Parse { .. }));
717    }
718
719    #[test]
720    fn test_validation_field_error_creation() {
721        let field_err = ValidationFieldError::new("user.email", "pattern", "Invalid email format");
722        assert_eq!(field_err.field, "user.email");
723        assert_eq!(field_err.rule_type, "pattern");
724        assert_eq!(field_err.message, "Invalid email format");
725    }
726
727    #[test]
728    fn test_levenshtein_ascii() {
729        // Basic sanity
730        assert_eq!(FraiseQLError::levenshtein_distance("kitten", "sitting"), 3);
731        assert_eq!(FraiseQLError::levenshtein_distance("", "abc"), 3);
732        assert_eq!(FraiseQLError::levenshtein_distance("abc", ""), 3);
733        assert_eq!(FraiseQLError::levenshtein_distance("same", "same"), 0);
734    }
735
736    #[test]
737    fn test_levenshtein_multibyte_utf8() {
738        // "café" is 4 chars but 5 bytes — previously the byte-length bug returned
739        // matrix[5][5] instead of matrix[4][4], which was an unmodified zero cell.
740        assert_eq!(FraiseQLError::levenshtein_distance("café", "cafe"), 1);
741        assert_eq!(FraiseQLError::levenshtein_distance("naïve", "naive"), 1);
742        // Two multi-byte strings: distance should equal number of differing chars
743        assert_eq!(FraiseQLError::levenshtein_distance("café", "café"), 0);
744    }
745}