1use std::fmt;
61use thiserror::Error;
62
63pub type QueryResult<T> = Result<T, QueryError>;
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub enum ErrorCode {
69 RecordNotFound = 1001,
72 NotUnique = 1002,
74 InvalidFilter = 1003,
76 InvalidSelect = 1004,
78 RequiredFieldMissing = 1005,
80
81 UniqueConstraint = 2001,
84 ForeignKeyConstraint = 2002,
86 CheckConstraint = 2003,
88 NotNullConstraint = 2004,
90
91 ConnectionFailed = 3001,
94 PoolExhausted = 3002,
96 ConnectionTimeout = 3003,
98 AuthenticationFailed = 3004,
100 SslError = 3005,
102
103 TransactionFailed = 4001,
106 Deadlock = 4002,
108 SerializationFailure = 4003,
110 TransactionClosed = 4004,
112
113 QueryTimeout = 5001,
116 SqlSyntax = 5002,
118 InvalidParameter = 5003,
120 QueryTooComplex = 5004,
122 DatabaseError = 5005,
124
125 InvalidDataType = 6001,
128 SerializationError = 6002,
130 DeserializationError = 6003,
132 DataTruncation = 6004,
134
135 InvalidConfiguration = 7001,
138 MissingConfiguration = 7002,
140 InvalidConnectionString = 7003,
142
143 Internal = 9001,
146 Unknown = 9999,
148}
149
150impl ErrorCode {
151 pub fn code(&self) -> String {
153 format!("P{}", *self as u16)
154 }
155
156 pub fn description(&self) -> &'static str {
158 match self {
159 Self::RecordNotFound => "Record not found",
160 Self::NotUnique => "Multiple records found",
161 Self::InvalidFilter => "Invalid filter condition",
162 Self::InvalidSelect => "Invalid select or include",
163 Self::RequiredFieldMissing => "Required field missing",
164 Self::UniqueConstraint => "Unique constraint violation",
165 Self::ForeignKeyConstraint => "Foreign key constraint violation",
166 Self::CheckConstraint => "Check constraint violation",
167 Self::NotNullConstraint => "Not null constraint violation",
168 Self::ConnectionFailed => "Database connection failed",
169 Self::PoolExhausted => "Connection pool exhausted",
170 Self::ConnectionTimeout => "Connection timeout",
171 Self::AuthenticationFailed => "Authentication failed",
172 Self::SslError => "SSL/TLS error",
173 Self::TransactionFailed => "Transaction failed",
174 Self::Deadlock => "Deadlock detected",
175 Self::SerializationFailure => "Serialization failure",
176 Self::TransactionClosed => "Transaction already closed",
177 Self::QueryTimeout => "Query timeout",
178 Self::SqlSyntax => "SQL syntax error",
179 Self::InvalidParameter => "Invalid parameter",
180 Self::QueryTooComplex => "Query too complex",
181 Self::DatabaseError => "Database error",
182 Self::InvalidDataType => "Invalid data type",
183 Self::SerializationError => "Serialization error",
184 Self::DeserializationError => "Deserialization error",
185 Self::DataTruncation => "Data truncation",
186 Self::InvalidConfiguration => "Invalid configuration",
187 Self::MissingConfiguration => "Missing configuration",
188 Self::InvalidConnectionString => "Invalid connection string",
189 Self::Internal => "Internal error",
190 Self::Unknown => "Unknown error",
191 }
192 }
193
194 pub fn docs_url(&self) -> String {
196 format!("https://prax.rs/docs/errors/{}", self.code())
197 }
198}
199
200impl fmt::Display for ErrorCode {
201 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202 write!(f, "{}", self.code())
203 }
204}
205
206#[derive(Debug, Clone)]
208pub struct Suggestion {
209 pub text: String,
211 pub code: Option<String>,
213}
214
215impl Suggestion {
216 pub fn new(text: impl Into<String>) -> Self {
218 Self {
219 text: text.into(),
220 code: None,
221 }
222 }
223
224 pub fn with_code(mut self, code: impl Into<String>) -> Self {
226 self.code = Some(code.into());
227 self
228 }
229}
230
231#[derive(Debug, Clone, Default)]
233pub struct ErrorContext {
234 pub operation: Option<String>,
236 pub model: Option<String>,
238 pub field: Option<String>,
240 pub sql: Option<String>,
242 pub suggestions: Vec<Suggestion>,
244 pub help: Option<String>,
246 pub related: Vec<String>,
248}
249
250impl ErrorContext {
251 pub fn new() -> Self {
253 Self::default()
254 }
255
256 pub fn operation(mut self, op: impl Into<String>) -> Self {
258 self.operation = Some(op.into());
259 self
260 }
261
262 pub fn model(mut self, model: impl Into<String>) -> Self {
264 self.model = Some(model.into());
265 self
266 }
267
268 pub fn field(mut self, field: impl Into<String>) -> Self {
270 self.field = Some(field.into());
271 self
272 }
273
274 pub fn sql(mut self, sql: impl Into<String>) -> Self {
276 self.sql = Some(sql.into());
277 self
278 }
279
280 pub fn suggestion(mut self, suggestion: Suggestion) -> Self {
282 self.suggestions.push(suggestion);
283 self
284 }
285
286 pub fn suggest(mut self, text: impl Into<String>) -> Self {
288 self.suggestions.push(Suggestion::new(text));
289 self
290 }
291
292 pub fn help(mut self, help: impl Into<String>) -> Self {
294 self.help = Some(help.into());
295 self
296 }
297}
298
299#[derive(Error, Debug)]
301pub struct QueryError {
302 pub code: ErrorCode,
304 pub message: String,
306 pub context: ErrorContext,
308 #[source]
310 pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
311}
312
313impl fmt::Display for QueryError {
314 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315 write!(f, "[{}] {}", self.code.code(), self.message)
316 }
317}
318
319impl QueryError {
320 pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
322 Self {
323 code,
324 message: message.into(),
325 context: ErrorContext::default(),
326 source: None,
327 }
328 }
329
330 pub fn with_context(mut self, operation: impl Into<String>) -> Self {
332 self.context.operation = Some(operation.into());
333 self
334 }
335
336 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
338 self.context.suggestions.push(Suggestion::new(suggestion));
339 self
340 }
341
342 pub fn with_code_suggestion(mut self, text: impl Into<String>, code: impl Into<String>) -> Self {
344 self.context.suggestions.push(Suggestion::new(text).with_code(code));
345 self
346 }
347
348 pub fn with_help(mut self, help: impl Into<String>) -> Self {
350 self.context.help = Some(help.into());
351 self
352 }
353
354 pub fn with_model(mut self, model: impl Into<String>) -> Self {
356 self.context.model = Some(model.into());
357 self
358 }
359
360 pub fn with_field(mut self, field: impl Into<String>) -> Self {
362 self.context.field = Some(field.into());
363 self
364 }
365
366 pub fn with_sql(mut self, sql: impl Into<String>) -> Self {
368 self.context.sql = Some(sql.into());
369 self
370 }
371
372 pub fn with_source<E: std::error::Error + Send + Sync + 'static>(mut self, source: E) -> Self {
374 self.source = Some(Box::new(source));
375 self
376 }
377
378 pub fn not_found(model: impl Into<String>) -> Self {
382 let model = model.into();
383 Self::new(
384 ErrorCode::RecordNotFound,
385 format!("No {} record found matching the query", model),
386 )
387 .with_model(&model)
388 .with_suggestion(format!("Verify the {} exists before querying", model))
389 .with_code_suggestion(
390 "Use findFirst() instead to get None instead of an error",
391 format!("client.{}().find_first().r#where(...).exec().await", model.to_lowercase()),
392 )
393 }
394
395 pub fn not_unique(model: impl Into<String>) -> Self {
397 let model = model.into();
398 Self::new(
399 ErrorCode::NotUnique,
400 format!("Expected unique {} record but found multiple", model),
401 )
402 .with_model(&model)
403 .with_suggestion("Add more specific filters to narrow down to a single record")
404 .with_suggestion("Use find_many() if you expect multiple results")
405 }
406
407 pub fn constraint_violation(model: impl Into<String>, message: impl Into<String>) -> Self {
409 let model = model.into();
410 let message = message.into();
411 Self::new(
412 ErrorCode::UniqueConstraint,
413 format!("Constraint violation on {}: {}", model, message),
414 )
415 .with_model(&model)
416 }
417
418 pub fn unique_violation(model: impl Into<String>, field: impl Into<String>) -> Self {
420 let model = model.into();
421 let field = field.into();
422 Self::new(
423 ErrorCode::UniqueConstraint,
424 format!("Unique constraint violated on {}.{}", model, field),
425 )
426 .with_model(&model)
427 .with_field(&field)
428 .with_suggestion(format!("A record with this {} already exists", field))
429 .with_code_suggestion(
430 "Use upsert() to update if exists, create if not",
431 format!(
432 "client.{}().upsert()\n .r#where({}::{}::equals(value))\n .create(...)\n .update(...)\n .exec().await",
433 model.to_lowercase(), model.to_lowercase(), field
434 ),
435 )
436 }
437
438 pub fn foreign_key_violation(model: impl Into<String>, relation: impl Into<String>) -> Self {
440 let model = model.into();
441 let relation = relation.into();
442 Self::new(
443 ErrorCode::ForeignKeyConstraint,
444 format!("Foreign key constraint violated: {} -> {}", model, relation),
445 )
446 .with_model(&model)
447 .with_field(&relation)
448 .with_suggestion(format!("Ensure the related {} record exists before creating this {}", relation, model))
449 .with_suggestion("Check for typos in the relation ID")
450 }
451
452 pub fn not_null_violation(model: impl Into<String>, field: impl Into<String>) -> Self {
454 let model = model.into();
455 let field = field.into();
456 Self::new(
457 ErrorCode::NotNullConstraint,
458 format!("Cannot set {}.{} to null - field is required", model, field),
459 )
460 .with_model(&model)
461 .with_field(&field)
462 .with_suggestion(format!("Provide a value for the {} field", field))
463 .with_help("Make the field optional in your schema if null should be allowed")
464 }
465
466 pub fn invalid_input(field: impl Into<String>, message: impl Into<String>) -> Self {
468 let field = field.into();
469 let message = message.into();
470 Self::new(
471 ErrorCode::InvalidParameter,
472 format!("Invalid input for {}: {}", field, message),
473 )
474 .with_field(&field)
475 }
476
477 pub fn connection(message: impl Into<String>) -> Self {
479 let message = message.into();
480 Self::new(ErrorCode::ConnectionFailed, format!("Connection error: {}", message))
481 .with_suggestion("Check that the database server is running")
482 .with_suggestion("Verify the connection URL is correct")
483 .with_suggestion("Check firewall settings allow the connection")
484 }
485
486 pub fn connection_timeout(duration_ms: u64) -> Self {
488 Self::new(
489 ErrorCode::ConnectionTimeout,
490 format!("Connection timed out after {}ms", duration_ms),
491 )
492 .with_suggestion("Increase the connect_timeout in your connection string")
493 .with_suggestion("Check network connectivity to the database server")
494 .with_code_suggestion(
495 "Add connect_timeout to your connection URL",
496 "postgres://user:pass@host/db?connect_timeout=30",
497 )
498 }
499
500 pub fn pool_exhausted(max_connections: u32) -> Self {
502 Self::new(
503 ErrorCode::PoolExhausted,
504 format!("Connection pool exhausted (max {} connections)", max_connections),
505 )
506 .with_suggestion("Increase max_connections in pool configuration")
507 .with_suggestion("Ensure connections are being released properly")
508 .with_suggestion("Check for connection leaks in your application")
509 .with_help("Consider using connection pooling middleware like PgBouncer for high-traffic applications")
510 }
511
512 pub fn authentication_failed(message: impl Into<String>) -> Self {
514 let message = message.into();
515 Self::new(ErrorCode::AuthenticationFailed, format!("Authentication failed: {}", message))
516 .with_suggestion("Check username and password in connection string")
517 .with_suggestion("Verify the user has permission to access the database")
518 .with_suggestion("Check pg_hba.conf (PostgreSQL) or user privileges (MySQL)")
519 }
520
521 pub fn timeout(duration_ms: u64) -> Self {
523 Self::new(
524 ErrorCode::QueryTimeout,
525 format!("Query timed out after {}ms", duration_ms),
526 )
527 .with_suggestion("Optimize the query to run faster")
528 .with_suggestion("Add indexes to improve query performance")
529 .with_suggestion("Increase the query timeout if the query is expected to be slow")
530 .with_help("Consider paginating large result sets")
531 }
532
533 pub fn transaction(message: impl Into<String>) -> Self {
535 let message = message.into();
536 Self::new(ErrorCode::TransactionFailed, format!("Transaction error: {}", message))
537 }
538
539 pub fn deadlock() -> Self {
541 Self::new(ErrorCode::Deadlock, "Deadlock detected - transaction was rolled back".to_string())
542 .with_suggestion("Retry the transaction")
543 .with_suggestion("Access tables in a consistent order across transactions")
544 .with_suggestion("Keep transactions short to reduce lock contention")
545 .with_help("Deadlocks occur when two transactions wait for each other's locks")
546 }
547
548 pub fn sql_syntax(message: impl Into<String>, sql: impl Into<String>) -> Self {
550 let message = message.into();
551 let sql = sql.into();
552 Self::new(ErrorCode::SqlSyntax, format!("SQL syntax error: {}", message))
553 .with_sql(&sql)
554 .with_suggestion("Check the generated SQL for errors")
555 .with_help("This is likely a bug in Prax - please report it")
556 }
557
558 pub fn serialization(message: impl Into<String>) -> Self {
560 Self::new(ErrorCode::SerializationError, message.into())
561 }
562
563 pub fn deserialization(message: impl Into<String>) -> Self {
565 let message = message.into();
566 Self::new(ErrorCode::DeserializationError, format!("Failed to deserialize result: {}", message))
567 .with_suggestion("Check that the model matches the database schema")
568 .with_suggestion("Ensure data types are compatible")
569 }
570
571 pub fn database(message: impl Into<String>) -> Self {
573 let message = message.into();
574 Self::new(ErrorCode::DatabaseError, message)
575 .with_suggestion("Check the database logs for more details")
576 }
577
578 pub fn internal(message: impl Into<String>) -> Self {
580 let message = message.into();
581 Self::new(ErrorCode::Internal, format!("Internal error: {}", message))
582 .with_help("This is likely a bug in Prax - please report it at https://github.com/pegasusheavy/prax/issues")
583 }
584
585 pub fn is_not_found(&self) -> bool {
589 self.code == ErrorCode::RecordNotFound
590 }
591
592 pub fn is_constraint_violation(&self) -> bool {
594 matches!(
595 self.code,
596 ErrorCode::UniqueConstraint
597 | ErrorCode::ForeignKeyConstraint
598 | ErrorCode::CheckConstraint
599 | ErrorCode::NotNullConstraint
600 )
601 }
602
603 pub fn is_timeout(&self) -> bool {
605 matches!(self.code, ErrorCode::QueryTimeout | ErrorCode::ConnectionTimeout)
606 }
607
608 pub fn is_connection_error(&self) -> bool {
610 matches!(
611 self.code,
612 ErrorCode::ConnectionFailed
613 | ErrorCode::PoolExhausted
614 | ErrorCode::ConnectionTimeout
615 | ErrorCode::AuthenticationFailed
616 | ErrorCode::SslError
617 )
618 }
619
620 pub fn is_retryable(&self) -> bool {
622 matches!(
623 self.code,
624 ErrorCode::ConnectionTimeout
625 | ErrorCode::PoolExhausted
626 | ErrorCode::QueryTimeout
627 | ErrorCode::Deadlock
628 | ErrorCode::SerializationFailure
629 )
630 }
631
632 pub fn error_code(&self) -> &ErrorCode {
636 &self.code
637 }
638
639 pub fn docs_url(&self) -> String {
641 self.code.docs_url()
642 }
643
644 pub fn display_full(&self) -> String {
646 let mut output = String::new();
647
648 output.push_str(&format!("Error [{}]: {}\n", self.code.code(), self.message));
650
651 if let Some(ref op) = self.context.operation {
653 output.push_str(&format!(" → While: {}\n", op));
654 }
655 if let Some(ref model) = self.context.model {
656 output.push_str(&format!(" → Model: {}\n", model));
657 }
658 if let Some(ref field) = self.context.field {
659 output.push_str(&format!(" → Field: {}\n", field));
660 }
661
662 if let Some(ref sql) = self.context.sql {
664 let sql_display = if sql.len() > 200 {
665 format!("{}...", &sql[..200])
666 } else {
667 sql.clone()
668 };
669 output.push_str(&format!(" → SQL: {}\n", sql_display));
670 }
671
672 if !self.context.suggestions.is_empty() {
674 output.push_str("\nSuggestions:\n");
675 for (i, suggestion) in self.context.suggestions.iter().enumerate() {
676 output.push_str(&format!(" {}. {}\n", i + 1, suggestion.text));
677 if let Some(ref code) = suggestion.code {
678 output.push_str(&format!(" ```\n {}\n ```\n", code.replace('\n', "\n ")));
679 }
680 }
681 }
682
683 if let Some(ref help) = self.context.help {
685 output.push_str(&format!("\nHelp: {}\n", help));
686 }
687
688 output.push_str(&format!("\nMore info: {}\n", self.docs_url()));
690
691 output
692 }
693
694 pub fn display_colored(&self) -> String {
696 let mut output = String::new();
697
698 output.push_str(&format!(
700 "\x1b[1;31mError [{}]\x1b[0m: \x1b[1m{}\x1b[0m\n",
701 self.code.code(),
702 self.message
703 ));
704
705 if let Some(ref op) = self.context.operation {
707 output.push_str(&format!(" \x1b[2m→ While:\x1b[0m {}\n", op));
708 }
709 if let Some(ref model) = self.context.model {
710 output.push_str(&format!(" \x1b[2m→ Model:\x1b[0m {}\n", model));
711 }
712 if let Some(ref field) = self.context.field {
713 output.push_str(&format!(" \x1b[2m→ Field:\x1b[0m {}\n", field));
714 }
715
716 if !self.context.suggestions.is_empty() {
718 output.push_str("\n\x1b[1;33mSuggestions:\x1b[0m\n");
719 for (i, suggestion) in self.context.suggestions.iter().enumerate() {
720 output.push_str(&format!(" \x1b[33m{}.\x1b[0m {}\n", i + 1, suggestion.text));
721 if let Some(ref code) = suggestion.code {
722 output.push_str(&format!(
723 " \x1b[2m```\x1b[0m\n \x1b[36m{}\x1b[0m\n \x1b[2m```\x1b[0m\n",
724 code.replace('\n', "\n ")
725 ));
726 }
727 }
728 }
729
730 if let Some(ref help) = self.context.help {
732 output.push_str(&format!("\n\x1b[1;36mHelp:\x1b[0m {}\n", help));
733 }
734
735 output.push_str(&format!(
737 "\n\x1b[2mMore info:\x1b[0m \x1b[4;34m{}\x1b[0m\n",
738 self.docs_url()
739 ));
740
741 output
742 }
743}
744
745pub trait IntoQueryError {
747 fn into_query_error(self) -> QueryError;
749}
750
751impl<E: std::error::Error + Send + Sync + 'static> IntoQueryError for E {
752 fn into_query_error(self) -> QueryError {
753 QueryError::internal(self.to_string()).with_source(self)
754 }
755}
756
757#[macro_export]
759macro_rules! query_error {
760 ($code:expr, $msg:expr) => {
761 $crate::error::QueryError::new($code, $msg)
762 };
763 ($code:expr, $msg:expr, $($key:ident = $value:expr),+ $(,)?) => {{
764 let mut err = $crate::error::QueryError::new($code, $msg);
765 $(
766 err = err.$key($value);
767 )+
768 err
769 }};
770}
771
772#[cfg(test)]
773mod tests {
774 use super::*;
775
776 #[test]
777 fn test_error_code_format() {
778 assert_eq!(ErrorCode::RecordNotFound.code(), "P1001");
779 assert_eq!(ErrorCode::UniqueConstraint.code(), "P2001");
780 assert_eq!(ErrorCode::ConnectionFailed.code(), "P3001");
781 }
782
783 #[test]
784 fn test_not_found_error() {
785 let err = QueryError::not_found("User");
786 assert!(err.is_not_found());
787 assert!(err.message.contains("User"));
788 assert!(!err.context.suggestions.is_empty());
789 }
790
791 #[test]
792 fn test_unique_violation_error() {
793 let err = QueryError::unique_violation("User", "email");
794 assert!(err.is_constraint_violation());
795 assert_eq!(err.context.model, Some("User".to_string()));
796 assert_eq!(err.context.field, Some("email".to_string()));
797 }
798
799 #[test]
800 fn test_timeout_error() {
801 let err = QueryError::timeout(5000);
802 assert!(err.is_timeout());
803 assert!(err.message.contains("5000"));
804 }
805
806 #[test]
807 fn test_error_with_context() {
808 let err = QueryError::not_found("User")
809 .with_context("Finding user by email")
810 .with_suggestion("Use a different query method");
811
812 assert_eq!(err.context.operation, Some("Finding user by email".to_string()));
813 assert!(err.context.suggestions.len() >= 2); }
815
816 #[test]
817 fn test_retryable_errors() {
818 assert!(QueryError::timeout(1000).is_retryable());
819 assert!(QueryError::deadlock().is_retryable());
820 assert!(QueryError::pool_exhausted(10).is_retryable());
821 assert!(!QueryError::not_found("User").is_retryable());
822 }
823
824 #[test]
825 fn test_connection_errors() {
826 assert!(QueryError::connection("failed").is_connection_error());
827 assert!(QueryError::authentication_failed("bad password").is_connection_error());
828 assert!(QueryError::pool_exhausted(10).is_connection_error());
829 }
830
831 #[test]
832 fn test_display_full() {
833 let err = QueryError::unique_violation("User", "email")
834 .with_context("Creating new user");
835
836 let output = err.display_full();
837 assert!(output.contains("P2001"));
838 assert!(output.contains("User"));
839 assert!(output.contains("email"));
840 assert!(output.contains("Suggestions"));
841 }
842
843 #[test]
844 fn test_docs_url() {
845 let err = QueryError::not_found("User");
846 assert!(err.docs_url().contains("P1001"));
847 }
848
849 #[test]
850 fn test_error_macro() {
851 let err = query_error!(
852 ErrorCode::InvalidParameter,
853 "Invalid email format",
854 with_field = "email",
855 with_suggestion = "Use a valid email address"
856 );
857
858 assert_eq!(err.code, ErrorCode::InvalidParameter);
859 assert_eq!(err.context.field, Some("email".to_string()));
860 }
861
862 #[test]
863 fn test_suggestion_with_code() {
864 let err = QueryError::not_found("User")
865 .with_code_suggestion("Try this instead", "client.user().find_first()");
866
867 let suggestion = err.context.suggestions.last().unwrap();
868 assert!(suggestion.code.is_some());
869 }
870}