Skip to main content

es_entity/
error.rs

1//! Types for working with errors produced by es-entity.
2
3use thiserror::Error;
4
5/// Error type for entity hydration failures (reconstructing entities from events).
6#[derive(Error, Debug)]
7pub enum EntityHydrationError {
8    #[error("EntityHydrationError - UninitializedFieldError: {0}")]
9    UninitializedFieldError(#[from] derive_builder::UninitializedFieldError),
10    #[error("EntityHydrationError - Deserialization: {0}")]
11    EventDeserialization(#[from] serde_json::Error),
12}
13
14#[derive(Error, Debug)]
15#[error("CursorDestructureError: couldn't turn {0} into {1}")]
16pub struct CursorDestructureError(&'static str, &'static str);
17
18impl From<(&'static str, &'static str)> for CursorDestructureError {
19    fn from((name, variant): (&'static str, &'static str)) -> Self {
20        Self(name, variant)
21    }
22}
23
24#[doc(hidden)]
25/// Extracts the conflicting value from a PostgreSQL constraint violation detail message.
26///
27/// PostgreSQL formats unique violation details as:
28/// `Key (column)=(value) already exists.`
29///
30/// Returns `None` if the detail is missing or doesn't match the expected format.
31///
32/// **Security note:** the extracted value is attacker-influenced input that
33/// was rejected by a unique constraint and may be PII (e.g. an email
34/// address). Returning it to untrusted API clients enables user
35/// enumeration; logging it may place PII in log pipelines.
36pub fn parse_constraint_detail_value(detail: Option<&str>) -> Option<String> {
37    let detail = detail?;
38    let start = detail.find("=(")? + 2;
39    let end = detail.rfind(") already")?;
40    if start <= end {
41        Some(detail[start..end].to_string())
42    } else {
43        None
44    }
45}
46
47#[doc(hidden)]
48/// Extracts the conflicting value from a database error's constraint violation.
49///
50/// Downcasts to [`sqlx::postgres::PgDatabaseError`], reads its `detail()`,
51/// and parses the conflicting value.
52///
53/// **Security note:** see [`parse_constraint_detail_value`] — the returned
54/// value may be PII and must not be exposed to untrusted clients.
55pub fn extract_constraint_value(db_err: &dyn sqlx::error::DatabaseError) -> Option<String> {
56    let pg_err = db_err.try_downcast_ref::<sqlx::postgres::PgDatabaseError>()?;
57    parse_constraint_detail_value(pg_err.detail())
58}
59
60/// The kind of database constraint behind a classified `ConstraintViolation`.
61///
62/// Returned by the generated `{Entity}Constraint::kind()` method.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum ConstraintKind {
65    Unique,
66    ForeignKey,
67    Check,
68}
69
70#[doc(hidden)]
71/// `true` when the database error is a violation kind the generated repo
72/// classifiers surface as `ConstraintViolation`: unique (SQLSTATE 23505),
73/// foreign key (23503), or check (23514). `NOT NULL` (23502) and exclusion
74/// (23P01) violations are not classified and surface as `Sqlx`.
75pub fn is_classified_constraint_violation(db_err: &dyn sqlx::error::DatabaseError) -> bool {
76    db_err.is_unique_violation() || db_err.is_foreign_key_violation() || db_err.is_check_violation()
77}
78
79#[doc(hidden)]
80/// Wrapper used by generated code to format not-found values.
81/// Prefers `Display` over `Debug` via inherent-vs-trait method resolution.
82pub struct NotFoundValue<'a, T: ?Sized>(pub &'a T);
83
84impl<T: std::fmt::Display + ?Sized> NotFoundValue<'_, T> {
85    pub fn to_not_found_value(&self) -> String {
86        self.0.to_string()
87    }
88}
89
90#[doc(hidden)]
91pub trait ToNotFoundValueFallback {
92    fn to_not_found_value(&self) -> String;
93}
94
95impl<T: std::fmt::Debug + ?Sized> ToNotFoundValueFallback for NotFoundValue<'_, T> {
96    fn to_not_found_value(&self) -> String {
97        format!("{:?}", self.0)
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use proptest::prelude::*;
105
106    #[test]
107    fn parse_simple_uuid_value() {
108        let detail = Some("Key (id)=(550e8400-e29b-41d4-a716-446655440000) already exists.");
109        assert_eq!(
110            parse_constraint_detail_value(detail),
111            Some("550e8400-e29b-41d4-a716-446655440000".to_string())
112        );
113    }
114
115    #[test]
116    fn parse_string_value() {
117        let detail = Some("Key (email)=(user@example.com) already exists.");
118        assert_eq!(
119            parse_constraint_detail_value(detail),
120            Some("user@example.com".to_string())
121        );
122    }
123
124    #[test]
125    fn parse_composite_key_value() {
126        let detail = Some("Key (tenant_id, email)=(abc, user@example.com) already exists.");
127        assert_eq!(
128            parse_constraint_detail_value(detail),
129            Some("abc, user@example.com".to_string())
130        );
131    }
132
133    #[test]
134    fn parse_none_detail() {
135        assert_eq!(parse_constraint_detail_value(None), None);
136    }
137
138    #[test]
139    fn parse_unexpected_format() {
140        let detail = Some("something unexpected");
141        assert_eq!(parse_constraint_detail_value(detail), None);
142    }
143
144    #[test]
145    fn parse_value_containing_parentheses() {
146        let detail = Some("Key (name)=(foo (bar)) already exists.");
147        assert_eq!(
148            parse_constraint_detail_value(detail),
149            Some("foo (bar)".to_string())
150        );
151    }
152
153    #[test]
154    fn parse_empty_value() {
155        let detail = Some("Key (col)=() already exists.");
156        assert_eq!(parse_constraint_detail_value(detail), Some("".to_string()));
157    }
158
159    #[test]
160    fn not_found_value_uses_display_when_available() {
161        #[allow(unused_imports)]
162        use crate::ToNotFoundValueFallback;
163
164        // String implements Display - should get clean output
165        let val = "hello";
166        assert_eq!(NotFoundValue(val).to_not_found_value(), "hello");
167
168        // i32 implements Display
169        let num = 42;
170        assert_eq!(NotFoundValue(&num).to_not_found_value(), "42");
171    }
172
173    #[test]
174    fn not_found_value_falls_back_to_debug() {
175        use crate::ToNotFoundValueFallback;
176
177        // A type with Debug but no Display
178        #[derive(Debug)]
179        #[allow(dead_code)]
180        struct OnlyDebug(i32);
181
182        let val = OnlyDebug(7);
183        assert_eq!(NotFoundValue(&val).to_not_found_value(), "OnlyDebug(7)");
184    }
185
186    proptest! {
187        /// The parser does byte-index arithmetic over attacker-controlled strings.
188        /// It must never panic, and any value it returns must be an honest
189        /// substring bounded by the real markers.
190        #[test]
191        fn constraint_detail_never_panics_and_is_honest(detail in ".*") {
192            match parse_constraint_detail_value(Some(&detail)) {
193                None => {}
194                Some(v) => {
195                    // Returned value is always a genuine substring of the input.
196                    prop_assert!(detail.contains(&v));
197                    // The markers that drove the indices must actually be present.
198                    let start = detail.find("=(").expect("start marker present") + 2;
199                    let end = detail
200                        .rfind(") already")
201                        .expect("end marker present");
202                    prop_assert!(start <= end);
203                    prop_assert_eq!(&detail[start..end], v.as_str());
204                }
205            }
206        }
207
208        #[test]
209        fn constraint_detail_none_input_returns_none(_ in Just(())) {
210            prop_assert_eq!(parse_constraint_detail_value(None), None);
211        }
212    }
213}