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#[doc(hidden)]
61/// Extracts the conflicting id from an events-table primary-key violation.
62///
63/// The events tables' primary key is `(id, sequence)`, so the violation
64/// detail reads `Key (id, sequence)=(<id>, <seq>) already exists.` — the id
65/// is everything before the last `, `. The sequence is an integer and can
66/// never contain `, `, so splitting at the last occurrence is unambiguous
67/// even for ids that themselves contain commas.
68///
69/// **Security note:** see [`parse_constraint_detail_value`] — the returned
70/// value may be PII and must not be exposed to untrusted clients.
71pub fn extract_events_pkey_id_value(db_err: &dyn sqlx::error::DatabaseError) -> Option<String> {
72    events_pkey_id_from_value(extract_constraint_value(db_err)?)
73}
74
75fn events_pkey_id_from_value(value: String) -> Option<String> {
76    match value.rsplit_once(", ") {
77        Some((id, _sequence)) => Some(id.to_string()),
78        None => Some(value),
79    }
80}
81
82/// The kind of database constraint behind a classified `ConstraintViolation`.
83///
84/// Returned by the generated `{Entity}Constraint::kind()` method.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum ConstraintKind {
87    Unique,
88    ForeignKey,
89    Check,
90}
91
92#[doc(hidden)]
93/// `true` when the database error is a violation kind the generated repo
94/// classifiers surface as `ConstraintViolation`: unique (SQLSTATE 23505),
95/// foreign key (23503), or check (23514). `NOT NULL` (23502) and exclusion
96/// (23P01) violations are not classified and surface as `Sqlx`.
97pub fn is_classified_constraint_violation(db_err: &dyn sqlx::error::DatabaseError) -> bool {
98    db_err.is_unique_violation() || db_err.is_foreign_key_violation() || db_err.is_check_violation()
99}
100
101#[doc(hidden)]
102/// Wrapper used by generated code to format not-found values.
103/// Prefers `Display` over `Debug` via inherent-vs-trait method resolution.
104pub struct NotFoundValue<'a, T: ?Sized>(pub &'a T);
105
106impl<T: std::fmt::Display + ?Sized> NotFoundValue<'_, T> {
107    pub fn to_not_found_value(&self) -> String {
108        self.0.to_string()
109    }
110}
111
112#[doc(hidden)]
113pub trait ToNotFoundValueFallback {
114    fn to_not_found_value(&self) -> String;
115}
116
117impl<T: std::fmt::Debug + ?Sized> ToNotFoundValueFallback for NotFoundValue<'_, T> {
118    fn to_not_found_value(&self) -> String {
119        format!("{:?}", self.0)
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use proptest::prelude::*;
127
128    #[test]
129    fn parse_simple_uuid_value() {
130        let detail = Some("Key (id)=(550e8400-e29b-41d4-a716-446655440000) already exists.");
131        assert_eq!(
132            parse_constraint_detail_value(detail),
133            Some("550e8400-e29b-41d4-a716-446655440000".to_string())
134        );
135    }
136
137    #[test]
138    fn parse_string_value() {
139        let detail = Some("Key (email)=(user@example.com) already exists.");
140        assert_eq!(
141            parse_constraint_detail_value(detail),
142            Some("user@example.com".to_string())
143        );
144    }
145
146    #[test]
147    fn parse_composite_key_value() {
148        let detail = Some("Key (tenant_id, email)=(abc, user@example.com) already exists.");
149        assert_eq!(
150            parse_constraint_detail_value(detail),
151            Some("abc, user@example.com".to_string())
152        );
153    }
154
155    #[test]
156    fn parse_none_detail() {
157        assert_eq!(parse_constraint_detail_value(None), None);
158    }
159
160    #[test]
161    fn parse_unexpected_format() {
162        let detail = Some("something unexpected");
163        assert_eq!(parse_constraint_detail_value(detail), None);
164    }
165
166    #[test]
167    fn parse_value_containing_parentheses() {
168        let detail = Some("Key (name)=(foo (bar)) already exists.");
169        assert_eq!(
170            parse_constraint_detail_value(detail),
171            Some("foo (bar)".to_string())
172        );
173    }
174
175    #[test]
176    fn events_pkey_id_strips_the_sequence() {
177        let value = parse_constraint_detail_value(Some(
178            "Key (id, sequence)=(550e8400-e29b-41d4-a716-446655440000, 1) already exists.",
179        ))
180        .unwrap();
181        assert_eq!(
182            events_pkey_id_from_value(value),
183            Some("550e8400-e29b-41d4-a716-446655440000".to_string())
184        );
185    }
186
187    #[test]
188    fn events_pkey_id_keeps_commas_inside_the_id() {
189        assert_eq!(
190            events_pkey_id_from_value("a, b, 3".to_string()),
191            Some("a, b".to_string())
192        );
193    }
194
195    #[test]
196    fn events_pkey_id_without_separator_returns_value_as_is() {
197        assert_eq!(
198            events_pkey_id_from_value("plain".to_string()),
199            Some("plain".to_string())
200        );
201    }
202
203    #[test]
204    fn parse_empty_value() {
205        let detail = Some("Key (col)=() already exists.");
206        assert_eq!(parse_constraint_detail_value(detail), Some("".to_string()));
207    }
208
209    #[test]
210    fn not_found_value_uses_display_when_available() {
211        #[allow(unused_imports)]
212        use crate::ToNotFoundValueFallback;
213
214        // String implements Display - should get clean output
215        let val = "hello";
216        assert_eq!(NotFoundValue(val).to_not_found_value(), "hello");
217
218        // i32 implements Display
219        let num = 42;
220        assert_eq!(NotFoundValue(&num).to_not_found_value(), "42");
221    }
222
223    #[test]
224    fn not_found_value_falls_back_to_debug() {
225        use crate::ToNotFoundValueFallback;
226
227        // A type with Debug but no Display
228        #[derive(Debug)]
229        #[allow(dead_code)]
230        struct OnlyDebug(i32);
231
232        let val = OnlyDebug(7);
233        assert_eq!(NotFoundValue(&val).to_not_found_value(), "OnlyDebug(7)");
234    }
235
236    proptest! {
237        /// The parser does byte-index arithmetic over attacker-controlled strings.
238        /// It must never panic, and any value it returns must be an honest
239        /// substring bounded by the real markers.
240        #[test]
241        fn constraint_detail_never_panics_and_is_honest(detail in ".*") {
242            match parse_constraint_detail_value(Some(&detail)) {
243                None => {}
244                Some(v) => {
245                    // Returned value is always a genuine substring of the input.
246                    prop_assert!(detail.contains(&v));
247                    // The markers that drove the indices must actually be present.
248                    let start = detail.find("=(").expect("start marker present") + 2;
249                    let end = detail
250                        .rfind(") already")
251                        .expect("end marker present");
252                    prop_assert!(start <= end);
253                    prop_assert_eq!(&detail[start..end], v.as_str());
254                }
255            }
256        }
257
258        #[test]
259        fn constraint_detail_none_input_returns_none(_ in Just(())) {
260            prop_assert_eq!(parse_constraint_detail_value(None), None);
261        }
262    }
263}